From c1c0cc1e51280cc1e5186e511a7a365d783ef697 Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Thu, 15 Jan 2026 10:11:20 +0100 Subject: [PATCH 01/20] feat: add CloudKit diagnostics and macOS signed build helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Settings → Cloud section with container ID, status, and test actions. - Add Tauri CloudKit commands + Rust CloudKit client (objc2) for Apple platforms. - Add signed macOS build helper script + entitlements file. - Avoid auto-triggering CloudKit calls when opening Settings when entitlements are misconfigured. - Add CLI flags to run CloudKit status/test from terminal for debugging. --- .gitignore | 5 + package.json | 1 + scripts/macos-build-signed.sh | 48 +++ src-tauri/Cargo.lock | 116 ++++--- src-tauri/Cargo.toml | 6 + src-tauri/entitlements.macos.plist | 32 ++ src-tauri/src/cloudkit.rs | 301 ++++++++++++++++++ src-tauri/src/lib.rs | 13 + src-tauri/src/main.rs | 39 +++ src-tauri/src/types.rs | 8 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 3 + .../settings/components/SettingsView.tsx | 249 ++++++++++++++- src/features/settings/hooks/useAppSettings.ts | 2 + src/services/tauri.ts | 10 + src/types.ts | 12 + 16 files changed, 795 insertions(+), 52 deletions(-) create mode 100755 scripts/macos-build-signed.sh create mode 100644 src-tauri/entitlements.macos.plist create mode 100644 src-tauri/src/cloudkit.rs diff --git a/.gitignore b/.gitignore index a757a9427..d1247ceaf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ dist-ssr /release-artifacts CodexMonitor.zip .codex-worktrees/ +codexmonitorMac.provisionprofile +screen-ipad-size.png +screen-ipad-size-small.png +src-tauri/gen/ +ilass-planing/transportAbstraction.md diff --git a/package.json b/package.json index 81fc04f28..e1666be12 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build:appimage": "NO_STRIP=1 tauri build --bundles appimage", "typecheck": "tsc --noEmit", "preview": "vite preview", + "macos:build-signed": "bash scripts/macos-build-signed.sh", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/macos-build-signed.sh b/scripts/macos-build-signed.sh new file mode 100755 index 000000000..a86f3e64e --- /dev/null +++ b/scripts/macos-build-signed.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +APP_PATH="$ROOT_DIR/src-tauri/target/release/bundle/macos/CodexMonitor.app" +PROFILE_PATH="${CODEXMONITOR_PROVISIONPROFILE:-$ROOT_DIR/codexmonitorMac.provisionprofile}" +ENTITLEMENTS_PATH="${CODEXMONITOR_ENTITLEMENTS:-$ROOT_DIR/src-tauri/entitlements.macos.plist}" +SIGNING_IDENTITY="${CODEXMONITOR_CODESIGN_IDENTITY:-Apple Development: Peter Vogel (HUDS4L39Y8)}" + +echo "[macos-build-signed] building…" +cd "$ROOT_DIR" +PATH="$HOME/.cargo/bin:$PATH" npm run tauri build + +if [[ ! -d "$APP_PATH" ]]; then + echo "[macos-build-signed] error: app bundle not found at: $APP_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$ENTITLEMENTS_PATH" ]]; then + echo "[macos-build-signed] error: entitlements file not found at: $ENTITLEMENTS_PATH" >&2 + exit 1 +fi + +if [[ ! -f "$PROFILE_PATH" ]]; then + echo "[macos-build-signed] error: provisioning profile not found at: $PROFILE_PATH" >&2 + echo "[macos-build-signed] tip: set CODEXMONITOR_PROVISIONPROFILE=/path/to/profile.provisionprofile" >&2 + exit 1 +fi + +echo "[macos-build-signed] embedding provisioning profile…" +cp "$PROFILE_PATH" "$APP_PATH/Contents/embedded.provisionprofile" + +# Provisioning profiles downloaded from the Apple Developer portal can carry a +# quarantine xattr (e.g. if downloaded via Safari). Keeping it inside the app +# bundle can prevent the app from launching via LaunchServices. +xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true + +echo "[macos-build-signed] signing (nested)…" +codesign --force --deep --sign "$SIGNING_IDENTITY" "$APP_PATH" + +echo "[macos-build-signed] signing (app entitlements)…" +codesign --force --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS_PATH" "$APP_PATH" + +echo "[macos-build-signed] verifying…" +codesign --verify --deep --strict --verbose=2 "$APP_PATH" + +echo "[macos-build-signed] done: $APP_PATH" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7e43f59ad..9bbea08c3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -458,9 +458,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "num-traits", @@ -472,9 +472,13 @@ dependencies = [ name = "codex-monitor" version = "0.1.0" dependencies = [ + "block2", "fix-path-env", "git2", "ignore", + "objc2", + "objc2-cloud-kit", + "objc2-foundation", "portable-pty", "serde", "serde_json", @@ -1017,9 +1021,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -1293,9 +1297,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1949,9 +1953,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -2378,6 +2382,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ "bitflags 2.10.0", + "block2", + "objc2", + "objc2-contacts", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b034b578389f89a85c055eacc8d8b368be5f04a6c1b07f672bf3aec21d0ef621" +dependencies = [ "objc2", "objc2-foundation", ] @@ -2427,6 +2444,16 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3177,7 +3204,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -3237,7 +3264,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 2.0.17", ] @@ -3364,7 +3391,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -4201,9 +4228,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05416b57601eca8666b5ec4186f5b1dc826ed35263b4797ad6641e58da6bc6c3" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" dependencies = [ "log", "raw-window-handle", @@ -4479,30 +4506,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -4669,9 +4696,9 @@ checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4982,18 +5009,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -5004,11 +5031,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -5017,9 +5045,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5027,9 +5055,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -5040,9 +5068,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -5062,9 +5090,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -5642,9 +5670,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -5753,9 +5781,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515214ab069b46f614dee52c1256015cdc1a0b441ed612118e2871014956741" +checksum = "17f79257df967b6779afa536788657777a0001f5b42524fcaf5038d4344df40b" dependencies = [ "async-broadcast", "async-executor", @@ -5788,9 +5816,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f54d8a5b4e9c46cf4a9732da4899b12851b5df952fc8deda23aca1d6f3e26c" +checksum = "aad23e2d2f91cae771c7af7a630a49e755f1eb74f8a46e9f6d5f7a146edf5a37" dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", @@ -5906,9 +5934,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea" [[package]] name = "zvariant" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cea35972c..918e66f50 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,5 +31,11 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } ignore = "0.4.25" portable-pty = "0.8" +[target."cfg(any(target_os = \"macos\", target_os = \"ios\"))".dependencies] +block2 = "0.6" +objc2 = { version = "0.6", features = ["exception"] } +objc2-cloud-kit = { version = "0.3", features = ["block2"] } +objc2-foundation = "0.3" + [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/entitlements.macos.plist b/src-tauri/entitlements.macos.plist new file mode 100644 index 000000000..6e649337e --- /dev/null +++ b/src-tauri/entitlements.macos.plist @@ -0,0 +1,32 @@ + + + + + com.apple.application-identifier + ZAMR4EWP34.com.ilass.codexmonitor + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-identifiers + + iCloud.com.ilass.codexmonitor + + com.apple.developer.icloud-container-development-container-identifiers + + iCloud.com.ilass.codexmonitor + + com.apple.developer.team-identifier + ZAMR4EWP34 + com.apple.developer.ubiquity-container-identifiers + + iCloud.com.ilass.codexmonitor + + com.apple.developer.ubiquity-kvstore-identifier + ZAMR4EWP34.* + keychain-access-groups + + ZAMR4EWP34.* + + + diff --git a/src-tauri/src/cloudkit.rs b/src-tauri/src/cloudkit.rs new file mode 100644 index 000000000..725b093ec --- /dev/null +++ b/src-tauri/src/cloudkit.rs @@ -0,0 +1,301 @@ +use serde::Serialize; +use tauri::State; + +use crate::state::AppState; + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitTestResult { + #[serde(rename = "recordName")] + pub(crate) record_name: String, + #[serde(rename = "durationMs")] + pub(crate) duration_ms: u64, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitStatus { + pub(crate) available: bool, + pub(crate) status: String, +} + +pub(crate) fn cloudkit_cli_status(container_id: String) -> Result { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::account_status_blocking(container_id) +} + +pub(crate) fn cloudkit_cli_test(container_id: String) -> Result { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::test_roundtrip_blocking(container_id) +} + +#[tauri::command] +pub(crate) async fn cloudkit_status(state: State<'_, AppState>) -> Result { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Ok(CloudKitStatus { + available: false, + status: "disabled".to_string(), + }); + } + + cloudkit_impl::ensure_cloudkit_allowed()?; + + let container_id = container_id + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .ok_or_else(|| { + "CloudKit container identifier is missing. Set it in Settings → Cloud.".to_string() + })?; + + tauri::async_runtime::spawn_blocking(move || cloudkit_impl::account_status_blocking(container_id)) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_test(state: State<'_, AppState>) -> Result { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Err("CloudKit Sync is disabled in Settings.".to_string()); + } + + cloudkit_impl::ensure_cloudkit_allowed()?; + + let container_id = container_id + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .ok_or_else(|| { + "CloudKit container identifier is missing. Set it in Settings → Cloud.".to_string() + })?; + + tauri::async_runtime::spawn_blocking(move || cloudkit_impl::test_roundtrip_blocking(container_id)) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +mod cloudkit_impl { + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + use block2::RcBlock; + use objc2::AnyThread; + use objc2::exception::catch; + use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::ProtocolObject; + use objc2_cloud_kit::{CKAccountStatus, CKContainer, CKDatabase, CKRecord, CKRecordID, CKRecordValue}; + use objc2_foundation::{NSError, NSString}; + use uuid::Uuid; + + use super::{CloudKitStatus, CloudKitTestResult}; + + fn exception_to_string(exception: Option>) -> String { + exception + .as_deref() + .map(|error| error.to_string()) + .unwrap_or_else(|| "Unknown Objective-C exception".to_string()) + } + + pub(super) fn ensure_cloudkit_allowed() -> Result<(), String> { + if cfg!(debug_assertions) && std::env::var("CODEXMONITOR_ALLOW_CLOUDKIT_DEV").ok().as_deref() != Some("1") { + return Err( + "CloudKit requires a signed build. Set CODEXMONITOR_ALLOW_CLOUDKIT_DEV=1 to override." + .to_string(), + ); + } + Ok(()) + } + + fn container_with_identifier(container_id: &str) -> Result, String> { + autoreleasepool(|_| { + let identifier = NSString::from_str(container_id); + catch(|| unsafe { CKContainer::containerWithIdentifier(&identifier) }) + .map_err(exception_to_string) + }) + } + + fn save_record_blocking(database: &CKDatabase, record: &CKRecord) -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::channel::>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |record_ptr: *mut CKRecord, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit save failed (error was null)".to_string())?; + return Err(error.localizedDescription().to_string()); + } + if record_ptr.is_null() { + return Err("CloudKit save failed (record was null)".to_string()); + } + Ok(()) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + database.saveRecord_completionHandler(record, &completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit save timed out".to_string())? + } + + fn fetch_record_blocking(database: &CKDatabase, record_id: &CKRecordID) -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::channel::>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |record_ptr: *mut CKRecord, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit fetch failed (error was null)".to_string())?; + return Err(error.localizedDescription().to_string()); + } + if record_ptr.is_null() { + return Err("CloudKit fetch failed (record was null)".to_string()); + } + let record = Retained::::retain(record_ptr) + .ok_or_else(|| "CloudKit fetch failed (record was null)".to_string())?; + let key = NSString::from_str("value"); + let value = record.objectForKey(&key); + if value.is_none() { + return Err("CloudKit fetch returned a record without the expected field.".to_string()); + } + Ok(()) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + database.fetchRecordWithID_completionHandler(record_id, &completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit fetch timed out".to_string())? + } + + pub(super) fn account_status_blocking(container_id: String) -> Result { + let container = container_with_identifier(&container_id)?; + let (tx, rx) = std::sync::mpsc::channel::>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |status: CKAccountStatus, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit status failed (error was null)".to_string())?; + return Err(error.localizedDescription().to_string()); + } + + let available = status == CKAccountStatus::Available; + let status_label = match status { + CKAccountStatus::Available => "available", + CKAccountStatus::NoAccount => "no-account", + CKAccountStatus::Restricted => "restricted", + CKAccountStatus::CouldNotDetermine => "unknown", + _ => "unknown", + }; + Ok(CloudKitStatus { + available, + status: status_label.to_string(), + }) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + container.accountStatusWithCompletionHandler(&completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit status timed out".to_string())? + } + + pub(super) fn test_roundtrip_blocking(container_id: String) -> Result { + let start = Instant::now(); + let container = container_with_identifier(&container_id)?; + + let record_name = format!("test-{}-{}", Uuid::new_v4(), start.elapsed().as_millis()); + + let record_id = autoreleasepool(|_| unsafe { + let name = NSString::from_str(&record_name); + CKRecordID::initWithRecordName(CKRecordID::alloc(), &name) + }); + + let record = autoreleasepool(|_| unsafe { + let record_type = NSString::from_str("CodexMonitorTest"); + CKRecord::initWithRecordType_recordID(CKRecord::alloc(), &record_type, &record_id) + }); + + autoreleasepool(|_| unsafe { + let key = NSString::from_str("value"); + let value = NSString::from_str("ok"); + let value: &ProtocolObject = ProtocolObject::from_ref(&*value); + record.setObject_forKey(Some(value), &key); + }); + + let database = catch(std::panic::AssertUnwindSafe(|| unsafe { + container.privateCloudDatabase() + })) + .map_err(exception_to_string)?; + + save_record_blocking(&database, &record)?; + fetch_record_blocking(&database, &record_id)?; + + Ok(CloudKitTestResult { + record_name, + duration_ms: start.elapsed().as_millis() as u64, + }) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "ios")))] +mod cloudkit_impl { + use super::{CloudKitStatus, CloudKitTestResult}; + + pub(super) fn ensure_cloudkit_allowed() -> Result<(), String> { + Err("CloudKit is only supported on Apple platforms.".to_string()) + } + + pub(super) fn account_status_blocking(_container_id: String) -> Result { + Err("CloudKit is only supported on Apple platforms.".to_string()) + } + + pub(super) fn test_roundtrip_blocking(_container_id: String) -> Result { + Err("CloudKit is only supported on Apple platforms.".to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af108ad8b..a844925c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; mod event_sink; +mod cloudkit; mod git; mod prompts; mod settings; @@ -14,6 +15,16 @@ mod types; mod utils; mod workspaces; +pub fn cloudkit_cli_status_json(container_id: String) -> Result { + let status = cloudkit::cloudkit_cli_status(container_id)?; + serde_json::to_string(&status).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_test_json(container_id: String) -> Result { + let result = cloudkit::cloudkit_cli_test(container_id)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[cfg(target_os = "linux")] @@ -138,6 +149,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ settings::get_app_settings, settings::update_app_settings, + cloudkit::cloudkit_status, + cloudkit::cloudkit_test, codex::codex_doctor, workspaces::list_workspaces, workspaces::add_workspace, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0b817d97c..27ae78a65 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,5 +5,44 @@ fn main() { if let Err(err) = fix_path_env::fix() { eprintln!("Failed to sync PATH from shell: {err}"); } + + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("--cloudkit-status") => { + let container_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() { + eprintln!("Usage: codex-monitor --cloudkit-status "); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_status_json(container_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-test") => { + let container_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() { + eprintln!("Usage: codex-monitor --cloudkit-test "); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_test_json(container_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + _ => {} + } codex_monitor_lib::run() } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f517317e5..ab64061b9 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -128,6 +128,10 @@ pub(crate) struct WorkspaceSettings { pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] pub(crate) codex_bin: Option, + #[serde(default, rename = "cloudKitEnabled")] + pub(crate) cloudkit_enabled: bool, + #[serde(default, rename = "cloudKitContainerId")] + pub(crate) cloudkit_container_id: Option, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, #[serde(default = "default_ui_scale", rename = "uiScale")] @@ -155,6 +159,8 @@ impl Default for AppSettings { fn default() -> Self { Self { codex_bin: None, + cloudkit_enabled: false, + cloudkit_container_id: None, default_access_mode: "current".to_string(), ui_scale: 1.0, notification_sounds_enabled: true, @@ -170,6 +176,8 @@ mod tests { fn app_settings_defaults_from_empty_json() { let settings: AppSettings = serde_json::from_str("{}").expect("settings deserialize"); assert!(settings.codex_bin.is_none()); + assert!(!settings.cloudkit_enabled); + assert!(settings.cloudkit_container_id.is_none()); assert_eq!(settings.default_access_mode, "current"); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); assert!(settings.notification_sounds_enabled); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 034071eec..fdf66a15b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,7 +39,7 @@ "bundle": { "active": true, "targets": "all", - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/App.tsx b/src/App.tsx index 6d42ddc55..d0451318e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useWindowFocusState } from "./features/layout/hooks/useWindowFocusState import { useCopyThread } from "./features/threads/hooks/useCopyThread"; import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; +import { cloudkitStatus, cloudkitTest } from "./services/tauri"; import { playNotificationSound } from "./utils/notificationSounds"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; @@ -959,6 +960,8 @@ function MainApp() { await queueSaveSettings(next); }} onRunDoctor={doctor} + onCloudKitStatus={cloudkitStatus} + onCloudKitTest={cloudkitTest} onUpdateWorkspaceCodexBin={async (id, codexBin) => { await updateWorkspaceCodexBin(id, codexBin); }} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 74e8c90d1..ae3264b59 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { ChevronDown, ChevronUp, + Cloud, LayoutGrid, SlidersHorizontal, Stethoscope, @@ -10,10 +11,14 @@ import { Trash2, X, } from "lucide-react"; -import type { AppSettings, CodexDoctorResult, WorkspaceInfo } from "../../../types"; -import { - clampUiScale, -} from "../../../utils/uiScale"; +import type { + AppSettings, + CloudKitStatus, + CloudKitTestResult, + CodexDoctorResult, + WorkspaceInfo, +} from "../../../types"; +import { clampUiScale } from "../../../utils/uiScale"; type SettingsViewProps = { workspaces: WorkspaceInfo[]; @@ -25,14 +30,16 @@ type SettingsViewProps = { appSettings: AppSettings; onUpdateAppSettings: (next: AppSettings) => Promise; onRunDoctor: (codexBin: string | null) => Promise; + onCloudKitStatus: () => Promise; + onCloudKitTest: () => Promise; onUpdateWorkspaceCodexBin: (id: string, codexBin: string | null) => Promise; scaleShortcutTitle: string; scaleShortcutText: string; onTestNotificationSound: () => void; }; -type SettingsSection = "projects" | "display"; -type CodexSection = SettingsSection | "codex"; +type SettingsSection = "projects" | "display" | "cloud"; +type SettingsTab = SettingsSection | "codex"; function orderValue(workspace: WorkspaceInfo) { const value = workspace.settings.sortOrder; @@ -49,22 +56,38 @@ export function SettingsView({ appSettings, onUpdateAppSettings, onRunDoctor, + onCloudKitStatus, + onCloudKitTest, onUpdateWorkspaceCodexBin, scaleShortcutTitle, scaleShortcutText, onTestNotificationSound, }: SettingsViewProps) { - const [activeSection, setActiveSection] = useState("projects"); + const [activeSection, setActiveSection] = useState("projects"); const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? ""); const [scaleDraft, setScaleDraft] = useState( `${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`, ); + const [cloudKitContainerDraft, setCloudKitContainerDraft] = useState( + appSettings.cloudKitContainerId ?? "", + ); const [overrideDrafts, setOverrideDrafts] = useState>({}); const [doctorState, setDoctorState] = useState<{ status: "idle" | "running" | "done"; result: CodexDoctorResult | null; }>({ status: "idle", result: null }); + const [cloudStatusState, setCloudStatusState] = useState<{ + status: "idle" | "running" | "done"; + result: CloudKitStatus | null; + error: string | null; + }>({ status: "idle", result: null, error: null }); + const [cloudTestState, setCloudTestState] = useState<{ + status: "idle" | "running" | "done"; + result: CloudKitTestResult | null; + error: string | null; + }>({ status: "idle", result: null, error: null }); const [isSavingSettings, setIsSavingSettings] = useState(false); + const [isSavingCloudSettings, setIsSavingCloudSettings] = useState(false); const projects = useMemo(() => { return workspaces @@ -87,6 +110,10 @@ export function SettingsView({ setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); }, [appSettings.uiScale]); + useEffect(() => { + setCloudKitContainerDraft(appSettings.cloudKitContainerId ?? ""); + }, [appSettings.cloudKitContainerId]); + useEffect(() => { setOverrideDrafts((prev) => { const next: Record = {}; @@ -106,6 +133,13 @@ export function SettingsView({ ? Number(trimmedScale.replace("%", "")) : Number.NaN; const parsedScale = Number.isFinite(parsedPercent) ? parsedPercent / 100 : null; + const cloudKitContainerDirty = + (cloudKitContainerDraft.trim() || null) !== + (appSettings.cloudKitContainerId ?? null); + + const cloudKitContainerConfigured = Boolean( + (appSettings.cloudKitContainerId ?? "").trim(), + ); const handleSaveCodexSettings = async () => { setIsSavingSettings(true); @@ -180,6 +214,75 @@ export function SettingsView({ } }; + const handleToggleCloudKit = async () => { + if (isSavingCloudSettings) { + return; + } + const nextEnabled = !appSettings.cloudKitEnabled; + setIsSavingCloudSettings(true); + try { + await onUpdateAppSettings({ + ...appSettings, + cloudKitEnabled: nextEnabled, + }); + setCloudStatusState({ status: "idle", result: null, error: null }); + setCloudTestState({ status: "idle", result: null, error: null }); + } finally { + setIsSavingCloudSettings(false); + } + }; + + const handleSaveCloudKitContainer = async () => { + if (isSavingCloudSettings) { + return; + } + setIsSavingCloudSettings(true); + try { + await onUpdateAppSettings({ + ...appSettings, + cloudKitContainerId: cloudKitContainerDraft.trim() + ? cloudKitContainerDraft.trim() + : null, + }); + setCloudStatusState({ status: "idle", result: null, error: null }); + setCloudTestState({ status: "idle", result: null, error: null }); + } finally { + setIsSavingCloudSettings(false); + } + }; + + const handleRunCloudStatus = async () => { + setCloudStatusState({ status: "running", result: null, error: null }); + try { + const result = await onCloudKitStatus(); + setCloudStatusState({ status: "done", result, error: null }); + } catch (error) { + setCloudStatusState({ + status: "done", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleRunCloudTest = async () => { + setCloudTestState({ status: "running", result: null, error: null }); + try { + const result = await onCloudKitTest(); + setCloudTestState({ status: "done", result, error: null }); + } catch (error) { + setCloudTestState({ + status: "done", + result: null, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + + // Intentionally do not auto-run CloudKit calls when the Cloud tab opens. + // Misconfigured entitlements can cause native exceptions, so we only run + // CloudKit operations via explicit user actions (buttons). + return (
@@ -213,6 +316,14 @@ export function SettingsView({ Display & Sound + +
+ +
+ +
+ + setCloudKitContainerDraft(event.target.value) + } + /> +
+
+ Use the iCloud container identifier enabled for this app. Example:{" "} + iCloud.com.ilass.codexmonitor. +
+
+ +
+ {cloudKitContainerDirty && ( + + )} + + +
+ + {cloudStatusState.status === "done" && ( +
+
+ {cloudStatusState.result?.available + ? "CloudKit account available" + : "CloudKit unavailable"} +
+
+
Status: {cloudStatusState.result?.status ?? "unknown"}
+ {cloudStatusState.error &&
{cloudStatusState.error}
} +
+
+ )} + + {cloudTestState.status === "done" && ( +
+
+ {cloudTestState.result ? "CloudKit test succeeded" : "CloudKit test failed"} +
+
+ {cloudTestState.result && ( +
+ Record: {cloudTestState.result.recordName} ( + {cloudTestState.result.durationMs} ms) +
+ )} + {cloudTestState.error &&
{cloudTestState.error}
} +
+
+ )} + + )} {activeSection === "codex" && (
Codex
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 029bdbede..55de6a45d 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -5,6 +5,8 @@ import { clampUiScale, UI_SCALE_DEFAULT } from "../../../utils/uiScale"; const defaultSettings: AppSettings = { codexBin: null, + cloudKitEnabled: false, + cloudKitContainerId: null, defaultAccessMode: "current", uiScale: UI_SCALE_DEFAULT, notificationSoundsEnabled: true, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 48eb8377c..adcdcc4dd 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { AppSettings, + CloudKitStatus, + CloudKitTestResult, CodexDoctorResult, WorkspaceInfo, WorkspaceSettings, @@ -203,6 +205,14 @@ export async function runCodexDoctor( return invoke("codex_doctor", { codexBin }); } +export async function cloudkitStatus(): Promise { + return invoke("cloudkit_status"); +} + +export async function cloudkitTest(): Promise { + return invoke("cloudkit_test"); +} + export async function getWorkspaceFiles(workspaceId: string) { return invoke("list_workspace_files", { workspaceId }); } diff --git a/src/types.ts b/src/types.ts index a4195b8f0..65a025c56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +63,8 @@ export type AccessMode = "read-only" | "current" | "full-access"; export type AppSettings = { codexBin: string | null; + cloudKitEnabled: boolean; + cloudKitContainerId: string | null; defaultAccessMode: AccessMode; uiScale: number; notificationSoundsEnabled: boolean; @@ -80,6 +82,16 @@ export type CodexDoctorResult = { nodeDetails: string | null; }; +export type CloudKitStatus = { + available: boolean; + status: string; +}; + +export type CloudKitTestResult = { + recordName: string; + durationMs: number; +}; + export type ApprovalRequest = { workspace_id: string; request_id: number; From 75f85d32ca514444d1121acffb46e325e919cbc0 Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 02:39:27 +0100 Subject: [PATCH 02/20] chore(ios): make simulator builds reproducible --- .gitignore | 1 + scripts/ios-build-sim.sh | 20 ++++ scripts/ios-run-sim.sh | 48 ++++++++ src-tauri/src/git_stub.rs | 65 +++++++++++ src-tauri/src/lib.rs | 203 ++++++++++++++++++---------------- src-tauri/tauri.conf.json | 5 +- src-tauri/tauri.ios.conf.json | 25 +++++ 7 files changed, 272 insertions(+), 95 deletions(-) create mode 100755 scripts/ios-build-sim.sh create mode 100755 scripts/ios-run-sim.sh create mode 100644 src-tauri/src/git_stub.rs create mode 100644 src-tauri/tauri.ios.conf.json diff --git a/.gitignore b/.gitignore index d1247ceaf..cd6fae564 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ screen-ipad-size.png screen-ipad-size-small.png src-tauri/gen/ ilass-planing/transportAbstraction.md +.run/ diff --git a/scripts/ios-build-sim.sh b/scripts/ios-build-sim.sh new file mode 100755 index 000000000..649548426 --- /dev/null +++ b/scripts/ios-build-sim.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Ensure we don't accidentally pick up an older MacPorts cargo (e.g. /opt/local/bin/cargo). +NODE_BIN="$(command -v node)" +NODE_DIR="$(cd "$(dirname "${NODE_BIN}")" && pwd)" + +export PATH="${ROOT_DIR}/node_modules/.bin:${HOME}/.cargo/bin:${NODE_DIR}:/usr/bin:/bin:/usr/sbin:/sbin" + +cd "${ROOT_DIR}" + +echo "[ios] Using cargo: $(command -v cargo)" +cargo -V + +rm -rf "${ROOT_DIR}/src-tauri/gen/apple/build" + +echo "[ios] Building (simulator)..." +npm run tauri -- ios build -d -t aarch64-sim --ci diff --git a/scripts/ios-run-sim.sh b/scripts/ios-run-sim.sh new file mode 100755 index 000000000..54eb5d915 --- /dev/null +++ b/scripts/ios-run-sim.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "${ROOT_DIR}" + +UDID="${1:-}" +if [[ -z "${UDID}" ]]; then + UDID="$(xcrun simctl list devices booted | rg -o '[0-9A-Fa-f-]{36}' | head -n 1 || true)" +fi + +if [[ -z "${UDID}" ]]; then + echo "[ios] No booted simulator found." + echo "[ios] Boot one first (e.g. via Simulator.app), then rerun:" + echo " scripts/ios-run-sim.sh" + exit 1 +fi + +OUT_DIR="${ROOT_DIR}/.run/ios" +mkdir -p "${OUT_DIR}" + +APP_PATH="${ROOT_DIR}/src-tauri/gen/apple/build/arm64-sim/CodexMonitor.app" +BUNDLE_ID="${BUNDLE_ID:-com.ilass.codexmonitor}" + +scripts/ios-build-sim.sh + +echo "[ios] Installing to simulator ${UDID}..." +xcrun simctl install "${UDID}" "${APP_PATH}" + +STDOUT_LOG="${OUT_DIR}/app-stdout.log" +STDERR_LOG="${OUT_DIR}/app-stderr.log" + +echo "[ios] Launching ${BUNDLE_ID} (logs: ${STDOUT_LOG}, ${STDERR_LOG})..." +xcrun simctl launch --terminate-running-process \ + --stdout="${STDOUT_LOG}" \ + --stderr="${STDERR_LOG}" \ + "${UDID}" "${BUNDLE_ID}" + +sleep 2 + +STAMP="$(date +%Y%m%d-%H%M%S)" +SCREENSHOT_PATH="${OUT_DIR}/screenshot-${STAMP}.png" +echo "[ios] Taking screenshot: ${SCREENSHOT_PATH}" +xcrun simctl io "${UDID}" screenshot "${SCREENSHOT_PATH}" + +echo "[ios] Done." + diff --git a/src-tauri/src/git_stub.rs b/src-tauri/src/git_stub.rs new file mode 100644 index 000000000..8b88cdb41 --- /dev/null +++ b/src-tauri/src/git_stub.rs @@ -0,0 +1,65 @@ +use tauri::State; + +use crate::state::AppState; +use crate::types::{GitFileDiff, GitLogResponse}; + +const GIT_IOS_UNAVAILABLE: &str = "Git features are not supported on iOS."; + +#[tauri::command] +pub(crate) async fn get_git_status( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn get_git_diffs( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result, String> { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn get_git_log( + _workspace_id: String, + _limit: Option, + _state: State<'_, AppState>, +) -> Result { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn get_git_remote( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result, String> { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn list_git_branches( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn checkout_git_branch( + _workspace_id: String, + _name: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} + +#[tauri::command] +pub(crate) async fn create_git_branch( + _workspace_id: String, + _name: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(GIT_IOS_UNAVAILABLE.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a844925c5..2e04d6fba 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,19 @@ +#[cfg(desktop)] use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}; -use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::Manager; +#[cfg(desktop)] +use tauri::{WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; mod event_sink; mod cloudkit; +#[cfg(not(target_os = "ios"))] mod git; +#[cfg(target_os = "ios")] +mod git_stub; +#[cfg(target_os = "ios")] +use git_stub as git; mod prompts; mod settings; mod state; @@ -35,106 +43,113 @@ pub fn run() { } } - tauri::Builder::default() - .enable_macos_default_menu(false) - .menu(|handle| { - let app_name = handle.package_info().name.clone(); - let about_item = MenuItemBuilder::with_id("about", format!("About {app_name}")) - .build(handle)?; - let app_menu = Submenu::with_items( - handle, - app_name, - true, - &[ - &about_item, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::services(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::hide(handle, None)?, - &PredefinedMenuItem::hide_others(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::quit(handle, None)?, - ], - )?; + let mut builder = tauri::Builder::default(); - let file_menu = Submenu::with_items( - handle, - "File", - true, - &[ - &PredefinedMenuItem::close_window(handle, None)?, - #[cfg(not(target_os = "macos"))] - &PredefinedMenuItem::quit(handle, None)?, - ], - )?; + #[cfg(desktop)] + { + builder = builder + .enable_macos_default_menu(false) + .menu(|handle| { + let app_name = handle.package_info().name.clone(); + let about_item = MenuItemBuilder::with_id("about", format!("About {app_name}")) + .build(handle)?; + let app_menu = Submenu::with_items( + handle, + app_name, + true, + &[ + &about_item, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::services(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::hide(handle, None)?, + &PredefinedMenuItem::hide_others(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::quit(handle, None)?, + ], + )?; - let edit_menu = Submenu::with_items( - handle, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(handle, None)?, - &PredefinedMenuItem::redo(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::cut(handle, None)?, - &PredefinedMenuItem::copy(handle, None)?, - &PredefinedMenuItem::paste(handle, None)?, - &PredefinedMenuItem::select_all(handle, None)?, - ], - )?; + let file_menu = Submenu::with_items( + handle, + "File", + true, + &[ + &PredefinedMenuItem::close_window(handle, None)?, + #[cfg(not(target_os = "macos"))] + &PredefinedMenuItem::quit(handle, None)?, + ], + )?; - let view_menu = Submenu::with_items( - handle, - "View", - true, - &[&PredefinedMenuItem::fullscreen(handle, None)?], - )?; + let edit_menu = Submenu::with_items( + handle, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(handle, None)?, + &PredefinedMenuItem::redo(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::cut(handle, None)?, + &PredefinedMenuItem::copy(handle, None)?, + &PredefinedMenuItem::paste(handle, None)?, + &PredefinedMenuItem::select_all(handle, None)?, + ], + )?; - let window_menu = Submenu::with_items( - handle, - "Window", - true, - &[ - &PredefinedMenuItem::minimize(handle, None)?, - &PredefinedMenuItem::maximize(handle, None)?, - &PredefinedMenuItem::separator(handle)?, - &PredefinedMenuItem::close_window(handle, None)?, - ], - )?; + let view_menu = Submenu::with_items( + handle, + "View", + true, + &[&PredefinedMenuItem::fullscreen(handle, None)?], + )?; - let help_menu = Submenu::with_items(handle, "Help", true, &[])?; + let window_menu = Submenu::with_items( + handle, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(handle, None)?, + &PredefinedMenuItem::maximize(handle, None)?, + &PredefinedMenuItem::separator(handle)?, + &PredefinedMenuItem::close_window(handle, None)?, + ], + )?; - Menu::with_items( - handle, - &[ - &app_menu, - &file_menu, - &edit_menu, - &view_menu, - &window_menu, - &help_menu, - ], - ) - }) - .on_menu_event(|app, event| { - if event.id() == "about" { - if let Some(window) = app.get_webview_window("about") { - let _ = window.show(); - let _ = window.set_focus(); - return; - } - let _ = WebviewWindowBuilder::new( - app, - "about", - WebviewUrl::App("index.html".into()), + let help_menu = Submenu::with_items(handle, "Help", true, &[])?; + + Menu::with_items( + handle, + &[ + &app_menu, + &file_menu, + &edit_menu, + &view_menu, + &window_menu, + &help_menu, + ], ) - .title("About Codex Monitor") - .resizable(false) - .inner_size(360.0, 240.0) - .center() - .build(); - } - }) + }) + .on_menu_event(|app, event| { + if event.id() == "about" { + if let Some(window) = app.get_webview_window("about") { + let _ = window.show(); + let _ = window.set_focus(); + return; + } + let _ = WebviewWindowBuilder::new( + app, + "about", + WebviewUrl::App("index.html".into()), + ) + .title("About Codex Monitor") + .resizable(false) + .inner_size(360.0, 240.0) + .center() + .build(); + } + }); + } + + builder .setup(|app| { let state = state::AppState::load(&app.handle()); app.manage(state); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fdf66a15b..154f7d3fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -46,7 +46,10 @@ "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico" - ] + ], + "iOS": { + "developmentTeam": "ZAMR4EWP34" + } }, "plugins": { "updater": { diff --git a/src-tauri/tauri.ios.conf.json b/src-tauri/tauri.ios.conf.json new file mode 100644 index 000000000..63ca7765e --- /dev/null +++ b/src-tauri/tauri.ios.conf.json @@ -0,0 +1,25 @@ +{ + "app": { + "security": { + "capabilities": [ + { + "identifier": "mobile-default", + "description": "Capability for the mobile window", + "windows": ["main", "about"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "process:default", + "core:window:allow-start-dragging" + ] + } + ] + } + }, + "bundle": { + "iOS": { + "developmentTeam": "ZAMR4EWP34" + } + } +} From cff101257c1a0819cedf321edc6af9d717985a6b Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 02:39:32 +0100 Subject: [PATCH 03/20] fix(ios): disable updater UI --- src/App.tsx | 3 ++- src/utils/platform.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/utils/platform.ts diff --git a/src/App.tsx b/src/App.tsx index d0451318e..29ee5a3f1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -63,6 +63,7 @@ import { usePanelVisibility } from "./features/layout/hooks/usePanelVisibility"; import { useTerminalController } from "./features/terminal/hooks/useTerminalController"; import { cloudkitStatus, cloudkitTest } from "./services/tauri"; import { playNotificationSound } from "./utils/notificationSounds"; +import { isAppleMobile } from "./utils/platform"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -145,7 +146,7 @@ function MainApp() { const composerInputRef = useRef(null); - const updater = useUpdater({ onDebug: addDebugEntry }); + const updater = useUpdater({ enabled: !isAppleMobile(), onDebug: addDebugEntry }); const isWindowFocused = useWindowFocusState(); const nextTestSoundIsError = useRef(false); diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 000000000..fa206f384 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,10 @@ +export function isAppleMobile(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + // Covers iPhone/iPad/iPod. (Modern iPadOS may report "Macintosh", but will still + // include iPad on most WebViews; we keep this simple for now.) + return /iPhone|iPad|iPod/i.test(navigator.userAgent); +} + From 152f2e7be2582e909cdcd099400747819895768d Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 02:57:06 +0100 Subject: [PATCH 04/20] fix(ios): disable updater on iPadOS --- src/utils/platform.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/utils/platform.ts b/src/utils/platform.ts index fa206f384..9330760c7 100644 --- a/src/utils/platform.ts +++ b/src/utils/platform.ts @@ -3,8 +3,18 @@ export function isAppleMobile(): boolean { return false; } - // Covers iPhone/iPad/iPod. (Modern iPadOS may report "Macintosh", but will still - // include iPad on most WebViews; we keep this simple for now.) - return /iPhone|iPad|iPod/i.test(navigator.userAgent); -} + // Covers iPhone/iPad/iPod. + if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { + return true; + } + // iPadOS 13+ often reports itself as "MacIntel" to request desktop sites. + // The reliable signal is touch support. + const platform = navigator.platform ?? ""; + const touchPoints = navigator.maxTouchPoints ?? 0; + if (platform === "MacIntel" && touchPoints > 1) { + return true; + } + + return false; +} From 37d1ac7c395405eba879c53710d1e2a1a7120696 Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 02:57:11 +0100 Subject: [PATCH 05/20] fix(cloudkit): allow iOS debug builds --- src-tauri/src/cloudkit.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/cloudkit.rs b/src-tauri/src/cloudkit.rs index 725b093ec..48675adc8 100644 --- a/src-tauri/src/cloudkit.rs +++ b/src-tauri/src/cloudkit.rs @@ -106,11 +106,17 @@ mod cloudkit_impl { } pub(super) fn ensure_cloudkit_allowed() -> Result<(), String> { - if cfg!(debug_assertions) && std::env::var("CODEXMONITOR_ALLOW_CLOUDKIT_DEV").ok().as_deref() != Some("1") { - return Err( - "CloudKit requires a signed build. Set CODEXMONITOR_ALLOW_CLOUDKIT_DEV=1 to override." - .to_string(), - ); + // We only hard-block debug builds on macOS, because macOS debug builds are often + // unsigned and CloudKit requires entitlements. On iOS, even debug builds are + // code-signed to run on devices/simulators, so we allow them by default. + let allow_debug = cfg!(target_os = "ios") + || std::env::var("CODEXMONITOR_ALLOW_CLOUDKIT_DEV") + .ok() + .as_deref() + == Some("1"); + + if cfg!(debug_assertions) && cfg!(target_os = "macos") && !allow_debug { + return Err("CloudKit requires a signed build. Set CODEXMONITOR_ALLOW_CLOUDKIT_DEV=1 to override.".to_string()); } Ok(()) } From 795fa746f8cafecbe58e62966b0607aba753e159 Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 05:59:56 +0100 Subject: [PATCH 06/20] feat(cloudkit): add runner sync + command bridge - Add signed macOS entitlements for CloudKit (Development environment). - Implement CloudKit runner presence + snapshot upserts and remote command queue. - Add CLI helpers for CloudKit debugging (runner/snapshots/commands). - Add Settings UI section for CloudKit configuration + test. --- package.json | 2 +- src-tauri/entitlements.macos.plist | 2 + src-tauri/src/cloudkit.rs | 1451 ++++++++++++++++- src-tauri/src/codex.rs | 1 - src-tauri/src/lib.rs | 65 + src-tauri/src/main.rs | 118 ++ src-tauri/src/state.rs | 46 +- src-tauri/src/types.rs | 42 +- .../settings/components/SettingsView.tsx | 308 ++++ src/features/settings/hooks/useAppSettings.ts | 44 +- src/services/tauri.ts | 52 + src/styles/settings.css | 2 + src/types.ts | 34 + 13 files changed, 2146 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index e1666be12..6a766938a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "macos:build-signed": "bash scripts/macos-build-signed.sh", - "tauri": "tauri" + "tauri": "PATH=$HOME/.cargo/bin:$PATH tauri" }, "dependencies": { "@tauri-apps/api": "^2", diff --git a/src-tauri/entitlements.macos.plist b/src-tauri/entitlements.macos.plist index 6e649337e..756a06bcc 100644 --- a/src-tauri/entitlements.macos.plist +++ b/src-tauri/entitlements.macos.plist @@ -16,6 +16,8 @@ iCloud.com.ilass.codexmonitor + com.apple.developer.icloud-container-environment + Development com.apple.developer.team-identifier ZAMR4EWP34 com.apple.developer.ubiquity-container-identifiers diff --git a/src-tauri/src/cloudkit.rs b/src-tauri/src/cloudkit.rs index 48675adc8..a1537c048 100644 --- a/src-tauri/src/cloudkit.rs +++ b/src-tauri/src/cloudkit.rs @@ -1,4 +1,5 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; use tauri::State; use crate::state::AppState; @@ -17,6 +18,55 @@ pub(crate) struct CloudKitStatus { pub(crate) status: String, } +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitRunnerInfo { + #[serde(rename = "runnerId")] + pub(crate) runner_id: String, + pub(crate) name: String, + pub(crate) platform: String, + #[serde(rename = "updatedAtMs")] + pub(crate) updated_at_ms: i64, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitSnapshot { + #[serde(rename = "scopeKey")] + pub(crate) scope_key: String, + #[serde(rename = "updatedAtMs")] + pub(crate) updated_at_ms: i64, + #[serde(rename = "payloadJson")] + pub(crate) payload_json: String, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitCommandAck { + #[serde(rename = "commandId")] + pub(crate) command_id: String, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct CloudKitCommandResult { + #[serde(rename = "commandId")] + pub(crate) command_id: String, + pub(crate) ok: bool, + #[serde(rename = "createdAtMs")] + pub(crate) created_at_ms: i64, + #[serde(rename = "payloadJson")] + pub(crate) payload_json: String, +} + +#[derive(Debug, Deserialize, Clone)] +struct IncomingCommand { + #[serde(rename = "commandId")] + command_id: String, + #[serde(rename = "clientId")] + client_id: Option, + #[serde(rename = "type")] + command_type: String, + #[serde(default)] + args: serde_json::Value, +} + pub(crate) fn cloudkit_cli_status(container_id: String) -> Result { cloudkit_impl::ensure_cloudkit_allowed()?; cloudkit_impl::account_status_blocking(container_id) @@ -27,6 +77,71 @@ pub(crate) fn cloudkit_cli_test(container_id: String) -> Result Result, String> { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::fetch_latest_runner_blocking(container_id) +} + +pub(crate) fn cloudkit_cli_upsert_runner( + container_id: String, + runner_id: String, +) -> Result { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::upsert_runner_presence_blocking( + container_id, + runner_id, + "CodexMonitor".to_string(), + "macos".to_string(), + ) +} + +pub(crate) fn cloudkit_cli_get_snapshot( + container_id: String, + runner_id: String, + scope_key: String, +) -> Result, String> { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::fetch_snapshot_blocking(container_id, runner_id, scope_key) +} + +pub(crate) fn cloudkit_cli_get_command_result( + container_id: String, + runner_id: String, + command_id: String, +) -> Result, String> { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::fetch_command_result_blocking(container_id, runner_id, command_id) +} + +pub(crate) fn cloudkit_cli_latest_command_result( + container_id: String, + runner_id: String, +) -> Result, String> { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::fetch_latest_command_result_blocking(container_id, runner_id) +} + +pub(crate) fn cloudkit_cli_submit_command( + container_id: String, + runner_id: String, + payload_json: String, +) -> Result { + cloudkit_impl::ensure_cloudkit_allowed()?; + + let command: IncomingCommand = + serde_json::from_str(&payload_json).map_err(|e| format!("Invalid command JSON: {e}"))?; + let command_id = command.command_id.clone(); + + cloudkit_impl::insert_command_blocking(container_id, runner_id, payload_json)?; + Ok(CloudKitCommandAck { command_id }) +} + +#[tauri::command] +pub(crate) async fn cloudkit_local_runner_id(state: State<'_, AppState>) -> Result { + let settings = state.app_settings.lock().await; + Ok(settings.runner_id.clone()) +} + #[tauri::command] pub(crate) async fn cloudkit_status(state: State<'_, AppState>) -> Result { let (enabled, container_id) = { @@ -82,21 +197,208 @@ pub(crate) async fn cloudkit_test(state: State<'_, AppState>) -> Result, +) -> Result { + let (enabled, container_id, runner_id) = { + let settings = state.app_settings.lock().await; + ( + settings.cloudkit_enabled, + settings.cloudkit_container_id.clone(), + settings.runner_id.clone(), + ) + }; + if !enabled { + return Err("CloudKit Sync is disabled in Settings.".to_string()); + } + + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + let name = name.trim().to_string(); + let platform = platform.trim().to_string(); + + tauri::async_runtime::spawn_blocking(move || { + cloudkit_impl::upsert_runner_presence_blocking(container_id, runner_id, name, platform) + }) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_fetch_latest_runner( + state: State<'_, AppState>, +) -> Result, String> { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Ok(None); + } + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + + tauri::async_runtime::spawn_blocking(move || cloudkit_impl::fetch_latest_runner_blocking(container_id)) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_put_snapshot( + scope_key: String, + payload_json: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let (enabled, container_id, runner_id) = { + let settings = state.app_settings.lock().await; + ( + settings.cloudkit_enabled, + settings.cloudkit_container_id.clone(), + settings.runner_id.clone(), + ) + }; + if !enabled { + return Ok(()); + } + + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + + tauri::async_runtime::spawn_blocking(move || { + cloudkit_impl::upsert_snapshot_blocking(container_id, runner_id, scope_key, payload_json) + }) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_get_snapshot( + runner_id: String, + scope_key: String, + state: State<'_, AppState>, +) -> Result, String> { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Ok(None); + } + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + + tauri::async_runtime::spawn_blocking(move || { + cloudkit_impl::fetch_snapshot_blocking(container_id, runner_id, scope_key) + }) + .await + .map_err(|_| "request canceled".to_string())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_submit_command( + runner_id: String, + payload_json: String, + state: State<'_, AppState>, +) -> Result { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Err("CloudKit Sync is disabled in Settings.".to_string()); + } + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + + let command: IncomingCommand = + serde_json::from_str(&payload_json).map_err(|e| format!("Invalid command JSON: {e}"))?; + let command_id = command.command_id.clone(); + + tauri::async_runtime::spawn_blocking(move || { + cloudkit_impl::insert_command_blocking(container_id, runner_id, payload_json) + }) + .await + .map_err(|_| "request canceled".to_string())??; + + Ok(CloudKitCommandAck { command_id }) +} + +#[tauri::command] +pub(crate) async fn cloudkit_get_command_result( + runner_id: String, + command_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let (enabled, container_id) = { + let settings = state.app_settings.lock().await; + (settings.cloudkit_enabled, settings.cloudkit_container_id.clone()) + }; + if !enabled { + return Ok(None); + } + cloudkit_impl::ensure_cloudkit_allowed()?; + let container_id = cloudkit_impl::require_container_id(container_id)?; + + tauri::async_runtime::spawn_blocking(move || { + cloudkit_impl::fetch_command_result_blocking(container_id, runner_id, command_id) + }) + .await + .map_err(|_| "request canceled".to_string())? +} + +pub(crate) fn start_cloudkit_command_poller(app: AppHandle) { + if !cfg!(target_os = "macos") { + return; + } + + tauri::async_runtime::spawn(async move { + cloudkit_impl::command_poller_loop(app).await; + }); +} + #[cfg(any(target_os = "macos", target_os = "ios"))] mod cloudkit_impl { + use std::collections::HashSet; + use std::hash::{Hash, Hasher}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; + use std::time::{SystemTime, UNIX_EPOCH}; use block2::RcBlock; use objc2::AnyThread; use objc2::exception::catch; use objc2::rc::{autoreleasepool, Retained}; + use objc2::runtime::AnyObject; use objc2::runtime::ProtocolObject; - use objc2_cloud_kit::{CKAccountStatus, CKContainer, CKDatabase, CKRecord, CKRecordID, CKRecordValue}; - use objc2_foundation::{NSError, NSString}; + use objc2_cloud_kit::{ + CKAccountStatus, CKContainer, CKDatabase, CKQuery, CKRecord, CKRecordID, CKRecordValue, + }; + use objc2_foundation::{NSArray, NSError, NSNumber, NSPredicate, NSSortDescriptor, NSString}; + use tauri::AppHandle; + use tauri::Manager; use uuid::Uuid; - use super::{CloudKitStatus, CloudKitTestResult}; + use crate::codex::spawn_workspace_session; + use crate::state::AppState; + use crate::types::{WorkspaceEntry, WorkspaceInfo}; + use super::{CloudKitCommandResult, CloudKitRunnerInfo, CloudKitSnapshot, CloudKitStatus, CloudKitTestResult, IncomingCommand}; + + fn debug_enabled() -> bool { + std::env::var("CODEXMONITOR_CLOUDKIT_DEBUG") + .ok() + .as_deref() + .map(|value| value == "1" || value.eq_ignore_ascii_case("true")) + .unwrap_or(false) + } + + fn debug_log(message: &str) { + if debug_enabled() { + eprintln!("[cloudkit] {message}"); + } + } fn exception_to_string(exception: Option>) -> String { exception @@ -105,6 +407,36 @@ mod cloudkit_impl { .unwrap_or_else(|| "Unknown Objective-C exception".to_string()) } + fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_millis() as i64 + } + + fn global_scope_key() -> String { + "g".to_string() + } + + fn workspace_scope_key(workspace_id: &str) -> String { + format!("ws/{workspace_id}") + } + + fn thread_scope_key(workspace_id: &str, thread_id: &str) -> String { + format!("th/{workspace_id}/{thread_id}") + } + + fn snapshot_envelope(scope_key: &str, runner_id: &str, payload: serde_json::Value) -> String { + serde_json::json!({ + "v": 1, + "ts": now_ms(), + "runnerId": runner_id, + "scopeKey": scope_key, + "payload": payload, + }) + .to_string() + } + pub(super) fn ensure_cloudkit_allowed() -> Result<(), String> { // We only hard-block debug builds on macOS, because macOS debug builds are often // unsigned and CloudKit requires entitlements. On iOS, even debug builds are @@ -121,6 +453,17 @@ mod cloudkit_impl { Ok(()) } + pub(super) fn require_container_id(container_id: Option) -> Result { + container_id + .and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + .ok_or_else(|| { + "CloudKit container identifier is missing. Set it in Settings → Cloud.".to_string() + }) + } + fn container_with_identifier(container_id: &str) -> Result, String> { autoreleasepool(|_| { let identifier = NSString::from_str(container_id); @@ -129,6 +472,27 @@ mod cloudkit_impl { }) } + fn private_database(container_id: &str) -> Result, String> { + let container = container_with_identifier(container_id)?; + catch(std::panic::AssertUnwindSafe(|| unsafe { container.privateCloudDatabase() })) + .map_err(exception_to_string) + } + + fn upsert_record_retained( + database: &CKDatabase, + record_type: &str, + record_id: &CKRecordID, + ) -> Result, String> { + if let Some(existing) = fetch_record_retained(database, record_id)? { + return Ok(existing); + } + + Ok(autoreleasepool(|_| unsafe { + let record_type = NSString::from_str(record_type); + CKRecord::initWithRecordType_recordID(CKRecord::alloc(), &record_type, record_id) + })) + } + fn save_record_blocking(database: &CKDatabase, record: &CKRecord) -> Result<(), String> { let (tx, rx) = std::sync::mpsc::channel::>(); let tx = Arc::new(Mutex::new(Some(tx))); @@ -206,6 +570,1085 @@ mod cloudkit_impl { .map_err(|_| "codexmonitor cloudkit fetch timed out".to_string())? } + fn fetch_record_retained(database: &CKDatabase, record_id: &CKRecordID) -> Result>, String> { + let (tx, rx) = std::sync::mpsc::channel::>, String>>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |record_ptr: *mut CKRecord, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit fetch failed (error was null)".to_string())?; + let message = error.localizedDescription().to_string(); + // Treat missing records as None. Different OS versions localize this as + // "Unknown Item" or "Record not found". + let lower = message.to_lowercase(); + if lower.contains("unknown item") || lower.contains("record not found") { + return Ok(None); + } + return Err(message); + } + if record_ptr.is_null() { + return Ok(None); + } + let record = Retained::::retain(record_ptr) + .ok_or_else(|| "CloudKit fetch failed (record was null)".to_string())?; + Ok(Some(record)) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + database.fetchRecordWithID_completionHandler(record_id, &completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit fetch timed out".to_string())? + } + + fn delete_record_blocking(database: &CKDatabase, record_id: &CKRecordID) -> Result<(), String> { + let (tx, rx) = std::sync::mpsc::channel::>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |record_id_ptr: *mut CKRecordID, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit delete failed (error was null)".to_string())?; + return Err(error.localizedDescription().to_string()); + } + if record_id_ptr.is_null() { + return Err("CloudKit delete failed (record id was null)".to_string()); + } + Ok(()) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + database.deleteRecordWithID_completionHandler(record_id, &completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit delete timed out".to_string())? + } + + fn perform_query_blocking( + database: &CKDatabase, + record_type: &str, + predicate_format: &str, + sort_key: Option<&str>, + ascending: bool, + ) -> Result>, String> { + let record_type = NSString::from_str(record_type); + let predicate_format = NSString::from_str(predicate_format); + let predicate = + unsafe { NSPredicate::predicateWithFormat_argumentArray(&predicate_format, None) }; + + let query = unsafe { CKQuery::initWithRecordType_predicate(CKQuery::alloc(), &record_type, &predicate) }; + if let Some(sort_key) = sort_key { + let key = NSString::from_str(sort_key); + let sort = NSSortDescriptor::sortDescriptorWithKey_ascending(Some(&key), ascending); + let sort_array = objc2_foundation::NSArray::from_slice(&[&*sort]); + unsafe { query.setSortDescriptors(Some(&sort_array)) }; + } + + let (tx, rx) = std::sync::mpsc::channel::>, String>>(); + let tx = Arc::new(Mutex::new(Some(tx))); + let tx_handle = tx.clone(); + + let completion = RcBlock::new(move |records_ptr: *mut NSArray, error_ptr: *mut NSError| { + let tx = match tx_handle.lock().ok().and_then(|mut guard| guard.take()) { + Some(sender) => sender, + None => return, + }; + + let outcome = autoreleasepool(|_| unsafe { + if !error_ptr.is_null() { + let error = Retained::::retain(error_ptr) + .ok_or_else(|| "CloudKit query failed (error was null)".to_string())?; + return Err(error.localizedDescription().to_string()); + } + if records_ptr.is_null() { + return Ok(Vec::new()); + } + let records = Retained::>::retain(records_ptr) + .ok_or_else(|| "CloudKit query failed (records was null)".to_string())?; + Ok(records.to_vec()) + }); + + let _ = tx.send(outcome); + }); + + catch(std::panic::AssertUnwindSafe(|| unsafe { + database.performQuery_inZoneWithID_completionHandler(&query, None, &completion); + })) + .map_err(exception_to_string)?; + + rx.recv_timeout(Duration::from_secs(15)) + .map_err(|_| "codexmonitor cloudkit query timed out".to_string())? + } + + fn scope_record_suffix(scope_key: &str) -> String { + // CloudKit record names are fairly permissive, but we keep it conservative and stable. + let mut sanitized = String::with_capacity(scope_key.len()); + for ch in scope_key.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + sanitized.push(ch); + } else { + sanitized.push('_'); + } + } + if sanitized == scope_key { + return sanitized; + } + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + scope_key.hash(&mut hasher); + let hash = hasher.finish(); + format!("{sanitized}-{hash:x}") + } + + fn snapshot_record_name(runner_id: &str, scope_key: &str) -> String { + format!("snap-{}-{}", runner_id, scope_record_suffix(scope_key)) + } + + fn runner_record_name(runner_id: &str) -> String { + format!("runner-{runner_id}") + } + + fn command_record_name(runner_id: &str, command_id: &str) -> String { + format!("cmd-{}-{}", runner_id, command_id) + } + + fn result_record_name(runner_id: &str, command_id: &str) -> String { + format!("res-{}-{}", runner_id, command_id) + } + + fn record_id_from_name(record_name: &str) -> Retained { + autoreleasepool(|_| unsafe { + let name = NSString::from_str(record_name); + CKRecordID::initWithRecordName(CKRecordID::alloc(), &name) + }) + } + + fn set_string_field(record: &CKRecord, key: &str, value: &str) { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = NSString::from_str(value); + let value: &ProtocolObject = ProtocolObject::from_ref(&*value); + record.setObject_forKey(Some(value), &key); + }); + } + + fn set_bool_field(record: &CKRecord, key: &str, value: bool) { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = NSNumber::numberWithBool(value); + let value: &ProtocolObject = ProtocolObject::from_ref(&*value); + record.setObject_forKey(Some(value), &key); + }); + } + + fn set_i64_field(record: &CKRecord, key: &str, value: i64) { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = NSNumber::numberWithLongLong(value as _); + let value: &ProtocolObject = ProtocolObject::from_ref(&*value); + record.setObject_forKey(Some(value), &key); + }); + } + + fn get_string_field(record: &CKRecord, key: &str) -> Option { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = record.objectForKey(&key)?; + let obj: &AnyObject = value.as_ref(); + let ptr = obj as *const AnyObject as *mut NSString; + let string = Retained::::retain(ptr)?; + Some(string.to_string()) + }) + } + + fn get_bool_field(record: &CKRecord, key: &str) -> Option { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = record.objectForKey(&key)?; + let obj: &AnyObject = value.as_ref(); + let ptr = obj as *const AnyObject as *mut NSNumber; + let number = Retained::::retain(ptr)?; + Some(number.boolValue()) + }) + } + + fn get_i64_field(record: &CKRecord, key: &str) -> Option { + autoreleasepool(|_| unsafe { + let key = NSString::from_str(key); + let value = record.objectForKey(&key)?; + let obj: &AnyObject = value.as_ref(); + let ptr = obj as *const AnyObject as *mut NSNumber; + let number = Retained::::retain(ptr)?; + Some(number.longLongValue() as i64) + }) + } + + pub(super) fn upsert_runner_presence_blocking( + container_id: String, + runner_id: String, + name: String, + platform: String, + ) -> Result { + let database = private_database(&container_id)?; + let updated_at_ms = now_ms(); + let record_id = record_id_from_name(&runner_record_name(&runner_id)); + let record = upsert_record_retained(&database, "CodexMonitorRunner", &record_id)?; + set_string_field(&record, "runnerId", &runner_id); + set_string_field(&record, "name", &name); + set_string_field(&record, "platform", &platform); + set_i64_field(&record, "updatedAtMs", updated_at_ms); + save_record_blocking(&database, &record)?; + Ok(CloudKitRunnerInfo { + runner_id, + name, + platform, + updated_at_ms, + }) + } + + pub(super) fn fetch_latest_runner_blocking(container_id: String) -> Result, String> { + let database = private_database(&container_id)?; + let records = perform_query_blocking( + &database, + "CodexMonitorRunner", + "updatedAtMs != 0", + Some("updatedAtMs"), + false, + )?; + let record = match records.first() { + Some(record) => record, + None => return Ok(None), + }; + let runner_id = get_string_field(record, "runnerId").unwrap_or_default(); + let name = get_string_field(record, "name").unwrap_or_default(); + let platform = get_string_field(record, "platform").unwrap_or_else(|| "unknown".to_string()); + let updated_at_ms = get_i64_field(record, "updatedAtMs").unwrap_or(0); + Ok(Some(CloudKitRunnerInfo { + runner_id, + name, + platform, + updated_at_ms, + })) + } + + pub(super) fn upsert_snapshot_blocking( + container_id: String, + runner_id: String, + scope_key: String, + payload_json: String, + ) -> Result<(), String> { + let database = private_database(&container_id)?; + let updated_at_ms = now_ms(); + let record_id = record_id_from_name(&snapshot_record_name(&runner_id, &scope_key)); + let record = upsert_record_retained(&database, "CodexMonitorSnapshot", &record_id)?; + set_string_field(&record, "runnerId", &runner_id); + set_string_field(&record, "scopeKey", &scope_key); + set_i64_field(&record, "updatedAtMs", updated_at_ms); + set_string_field(&record, "payload", &payload_json); + save_record_blocking(&database, &record) + } + + pub(super) fn fetch_snapshot_blocking( + container_id: String, + runner_id: String, + scope_key: String, + ) -> Result, String> { + let database = private_database(&container_id)?; + let record_id = record_id_from_name(&snapshot_record_name(&runner_id, &scope_key)); + let record = match fetch_record_retained(&database, &record_id)? { + Some(record) => record, + None => return Ok(None), + }; + let payload_json = get_string_field(&record, "payload").unwrap_or_default(); + let updated_at_ms = get_i64_field(&record, "updatedAtMs").unwrap_or(0); + Ok(Some(CloudKitSnapshot { + scope_key, + updated_at_ms, + payload_json, + })) + } + + pub(super) fn insert_command_blocking( + container_id: String, + runner_id: String, + payload_json: String, + ) -> Result<(), String> { + let command: IncomingCommand = + serde_json::from_str(&payload_json).map_err(|e| format!("Invalid command JSON: {e}"))?; + let database = private_database(&container_id)?; + let created_at_ms = now_ms(); + let record_id = record_id_from_name(&command_record_name(&runner_id, &command.command_id)); + let record = autoreleasepool(|_| unsafe { + let record_type = NSString::from_str("CodexMonitorCommand"); + CKRecord::initWithRecordType_recordID(CKRecord::alloc(), &record_type, &record_id) + }); + set_string_field(&record, "runnerId", &runner_id); + set_string_field(&record, "commandId", &command.command_id); + if let Some(client_id) = &command.client_id { + set_string_field(&record, "clientId", client_id); + } + set_string_field(&record, "type", &command.command_type); + set_i64_field(&record, "createdAtMs", created_at_ms); + set_string_field(&record, "status", "new"); + set_string_field(&record, "payload", &payload_json); + save_record_blocking(&database, &record) + } + + fn write_command_result_blocking( + container_id: String, + runner_id: String, + command_id: String, + ok: bool, + payload_json: String, + ) -> Result<(), String> { + let database = private_database(&container_id)?; + let created_at_ms = now_ms(); + let record_id = record_id_from_name(&result_record_name(&runner_id, &command_id)); + let record = upsert_record_retained(&database, "CodexMonitorCommandResult", &record_id)?; + set_string_field(&record, "runnerId", &runner_id); + set_string_field(&record, "commandId", &command_id); + set_bool_field(&record, "ok", ok); + set_i64_field(&record, "createdAtMs", created_at_ms); + set_string_field(&record, "payload", &payload_json); + save_record_blocking(&database, &record) + } + + pub(super) fn fetch_command_result_blocking( + container_id: String, + runner_id: String, + command_id: String, + ) -> Result, String> { + let database = private_database(&container_id)?; + let record_id = record_id_from_name(&result_record_name(&runner_id, &command_id)); + let record = match fetch_record_retained(&database, &record_id)? { + Some(record) => record, + None => return Ok(None), + }; + let ok = get_bool_field(&record, "ok").unwrap_or(false); + let created_at_ms = get_i64_field(&record, "createdAtMs").unwrap_or(0); + let payload_json = get_string_field(&record, "payload").unwrap_or_else(|| "{}".to_string()); + Ok(Some(CloudKitCommandResult { + command_id, + ok, + created_at_ms, + payload_json, + })) + } + + pub(super) fn fetch_latest_command_result_blocking( + container_id: String, + runner_id: String, + ) -> Result, String> { + let database = private_database(&container_id)?; + + let escaped_runner_id = runner_id.replace('\"', "\\\""); + let predicate = format!("runnerId == \"{escaped_runner_id}\""); + let records = match perform_query_blocking( + &database, + "CodexMonitorCommandResult", + &predicate, + Some("createdAtMs"), + false, + ) { + Ok(records) => records, + Err(error) => { + // CloudKit development schema may not have this record type yet. Treat as empty. + if error.to_lowercase().contains("did not find record type") { + return Ok(None); + } + return Err(error); + } + }; + let Some(record) = records.first() else { + return Ok(None); + }; + + let command_id = get_string_field(record, "commandId").unwrap_or_else(|| "".to_string()); + let ok = get_bool_field(record, "ok").unwrap_or(false); + let created_at_ms = get_i64_field(record, "createdAtMs").unwrap_or(0); + let payload_json = get_string_field(record, "payload").unwrap_or_else(|| "{}".to_string()); + + if command_id.trim().is_empty() { + return Ok(None); + } + + Ok(Some(CloudKitCommandResult { + command_id, + ok, + created_at_ms, + payload_json, + })) + } + + async fn ensure_workspace_connected( + workspace_id: &str, + state: &AppState, + app: &AppHandle, + ) -> Result<(), String> { + if state.sessions.lock().await.contains_key(workspace_id) { + return Ok(()); + } + let entry: WorkspaceEntry = { + let workspaces = state.workspaces.lock().await; + workspaces + .get(workspace_id) + .cloned() + .ok_or("workspace not found")? + }; + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let session = spawn_workspace_session(entry.clone(), default_bin, app.clone()).await?; + state + .sessions + .lock() + .await + .insert(entry.id.clone(), session); + Ok(()) + } + + async fn publish_global_snapshot( + container_id: &str, + runner_id: &str, + state: &AppState, + ) -> Result<(), String> { + let connected_ids: HashSet = { + let sessions = state.sessions.lock().await; + sessions.keys().cloned().collect() + }; + let list: Vec = { + let workspaces = state.workspaces.lock().await; + workspaces + .values() + .cloned() + .map(|entry: WorkspaceEntry| WorkspaceInfo { + id: entry.id.clone(), + name: entry.name.clone(), + path: entry.path.clone(), + connected: connected_ids.contains(&entry.id), + codex_bin: entry.codex_bin.clone(), + kind: entry.kind.clone(), + parent_id: entry.parent_id.clone(), + worktree: entry.worktree.clone(), + settings: entry.settings.clone(), + }) + .collect() + }; + + let payload = serde_json::json!({ "workspaces": list }); + let scope_key = global_scope_key(); + let json = snapshot_envelope(&scope_key, runner_id, payload); + upsert_snapshot_blocking(container_id.to_string(), runner_id.to_string(), scope_key, json)?; + Ok(()) + } + + async fn fetch_thread_summaries( + session: &crate::codex::WorkspaceSession, + ) -> Result, String> { + let mut matching: Vec = Vec::new(); + let target = 20usize; + let mut cursor: Option = None; + let workspace_path = session.entry.path.clone(); + + while matching.len() < target { + let response = session + .send_request( + "thread/list", + serde_json::json!({ + "cursor": cursor, + "limit": 20, + }), + ) + .await?; + + let result = response + .get("result") + .cloned() + .unwrap_or_else(|| response.clone()); + let data = result + .get("data") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + for entry in data { + let cwd = entry.get("cwd").and_then(|value| value.as_str()).unwrap_or(""); + if cwd == workspace_path { + matching.push(entry); + } + } + let next = result + .get("nextCursor") + .or_else(|| result.get("next_cursor")) + .and_then(|value| value.as_str()) + .map(|value| value.to_string()); + cursor = next; + if cursor.is_none() { + break; + } + } + + // Convert into minimal summaries {id, name}. + let summaries: Vec = matching + .into_iter() + .enumerate() + .filter_map(|(idx, thread)| { + let id = thread.get("id")?.as_str()?.to_string(); + let preview = thread + .get("preview") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let fallback = format!("Agent {}", idx + 1); + let mut name = if !preview.is_empty() { preview } else { fallback }; + if name.chars().count() > 38 { + name = name.chars().take(38).collect::() + "…"; + } + Some(serde_json::json!({ "id": id, "name": name })) + }) + .collect(); + + Ok(summaries) + } + + async fn publish_workspace_snapshot( + container_id: &str, + runner_id: &str, + workspace_id: &str, + state: &AppState, + ) -> Result<(), String> { + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(workspace_id) + .cloned() + .ok_or("workspace not connected")? + }; + + let threads = fetch_thread_summaries(&session).await?; + let scope_key = workspace_scope_key(workspace_id); + let payload = serde_json::json!({ "workspaceId": workspace_id, "threads": threads, "threadStatusById": {} }); + let json = snapshot_envelope(&scope_key, runner_id, payload); + + upsert_snapshot_blocking(container_id.to_string(), runner_id.to_string(), scope_key, json)?; + Ok(()) + } + + fn extract_thread_from_resume_response(response: &serde_json::Value) -> Option { + let result = response.get("result").cloned().unwrap_or_else(|| response.clone()); + result + .get("thread") + .cloned() + .or_else(|| response.get("thread").cloned()) + } + + fn latest_agent_text(thread: &serde_json::Value) -> Option { + let turns = thread.get("turns")?.as_array()?; + let mut last: Option = None; + for turn in turns { + let items = match turn.get("items").and_then(|v| v.as_array()) { + Some(items) => items, + None => continue, + }; + for item in items.iter() { + if item.get("type").and_then(|v| v.as_str()) == Some("agentMessage") { + if let Some(text) = item.get("text").and_then(|v| v.as_str()) { + if !text.trim().is_empty() { + last = Some(text.to_string()); + } + } + } + } + } + last + } + + fn truncate_chars(value: &str, max_chars: usize) -> String { + if max_chars == 0 { + return String::new(); + } + let mut chars = value.chars(); + let mut out = String::new(); + for _ in 0..max_chars { + if let Some(ch) = chars.next() { + out.push(ch); + } else { + return out; + } + } + if chars.next().is_some() { + out.push('…'); + } + out + } + + fn user_inputs_to_text(content: &serde_json::Value, max_chars: usize) -> String { + let Some(inputs) = content.as_array() else { + return String::new(); + }; + let mut parts: Vec = Vec::new(); + for input in inputs { + let Some(obj) = input.as_object() else { + continue; + }; + let input_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match input_type { + "text" => { + if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + parts.push(trimmed.to_string()); + } + } + } + "skill" => { + if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { + let trimmed = name.trim(); + if !trimmed.is_empty() { + parts.push(format!("${trimmed}")); + } + } + } + "image" | "localImage" => { + parts.push("[image]".to_string()); + } + _ => {} + } + } + let joined = parts.join(" "); + truncate_chars(joined.trim(), max_chars) + } + + fn build_message_items(thread: &serde_json::Value, max_items: usize, max_text_chars: usize) -> Vec { + let turns = thread.get("turns").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let mut out: Vec = Vec::new(); + for turn in turns { + let items = match turn.get("items").and_then(|v| v.as_array()) { + Some(items) => items, + None => continue, + }; + for item in items { + let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if id.is_empty() { + continue; + } + match item_type { + "userMessage" => { + let text = user_inputs_to_text(item.get("content").unwrap_or(&serde_json::Value::Null), max_text_chars); + let rendered = if text.trim().is_empty() { + "[message]".to_string() + } else { + text + }; + out.push(serde_json::json!({ + "id": id, + "kind": "message", + "role": "user", + "text": rendered, + })); + } + "agentMessage" => { + let text = item.get("text").and_then(|v| v.as_str()).unwrap_or(""); + let text = truncate_chars(text, max_text_chars); + let rendered = if text.trim().is_empty() { + "[message]".to_string() + } else { + text + }; + out.push(serde_json::json!({ + "id": id, + "kind": "message", + "role": "assistant", + "text": rendered, + })); + } + _ => {} + } + } + } + if max_items > 0 && out.len() > max_items { + out.drain(0..(out.len() - max_items)); + } + out + } + + async fn publish_thread_snapshot( + container_id: &str, + runner_id: &str, + workspace_id: &str, + thread_id: &str, + thread: serde_json::Value, + ) -> Result<(), String> { + let scope_key = thread_scope_key(workspace_id, thread_id); + let items = build_message_items(&thread, 200, 8000); + let payload = serde_json::json!({ + "workspaceId": workspace_id, + "threadId": thread_id, + "items": items, + "thread": null, + "status": null, + }); + let json = snapshot_envelope(&scope_key, runner_id, payload); + upsert_snapshot_blocking(container_id.to_string(), runner_id.to_string(), scope_key, json)?; + Ok(()) + } + + async fn execute_command( + command: IncomingCommand, + state: &AppState, + app: &AppHandle, + ) -> Result { + let (container_id, runner_id) = { + let settings = state.app_settings.lock().await; + ( + settings + .cloudkit_container_id + .clone() + .unwrap_or_default(), + settings.runner_id.clone(), + ) + }; + let container_id = container_id.trim().to_string(); + let can_publish = !container_id.is_empty(); + + match command.command_type.as_str() { + "connectWorkspace" => { + let workspace_id = command + .args + .get("workspaceId") + .and_then(|value| value.as_str()) + .ok_or("connectWorkspace requires args.workspaceId")?; + ensure_workspace_connected(workspace_id, state, app).await?; + if can_publish { + let _ = publish_global_snapshot(&container_id, &runner_id, state).await; + let _ = publish_workspace_snapshot(&container_id, &runner_id, workspace_id, state).await; + } + Ok(serde_json::json!({ "connected": true })) + } + "startThread" => { + let workspace_id = command + .args + .get("workspaceId") + .and_then(|value| value.as_str()) + .ok_or("startThread requires args.workspaceId")?; + ensure_workspace_connected(workspace_id, state, app).await?; + + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(workspace_id) + .cloned() + .ok_or("workspace not connected")? + }; + let params = serde_json::json!({ + "cwd": session.entry.path, + "approvalPolicy": "on-request" + }); + let response = session.send_request("thread/start", params).await?; + if can_publish { + let _ = publish_workspace_snapshot(&container_id, &runner_id, workspace_id, state).await; + } + Ok(response) + } + "resumeThread" => { + let workspace_id = command + .args + .get("workspaceId") + .and_then(|value| value.as_str()) + .ok_or("resumeThread requires args.workspaceId")?; + let thread_id = command + .args + .get("threadId") + .and_then(|value| value.as_str()) + .ok_or("resumeThread requires args.threadId")?; + ensure_workspace_connected(workspace_id, state, app).await?; + + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(workspace_id) + .cloned() + .ok_or("workspace not connected")? + }; + let params = serde_json::json!({ "threadId": thread_id }); + let response = session.send_request("thread/resume", params).await?; + if can_publish { + if let Some(thread) = extract_thread_from_resume_response(&response) { + let _ = publish_thread_snapshot(&container_id, &runner_id, workspace_id, thread_id, thread).await; + } + } + Ok(response) + } + "sendUserMessage" => { + let workspace_id = command + .args + .get("workspaceId") + .and_then(|value| value.as_str()) + .ok_or("sendUserMessage requires args.workspaceId")?; + let thread_id = command + .args + .get("threadId") + .and_then(|value| value.as_str()) + .ok_or("sendUserMessage requires args.threadId")?; + let text = command + .args + .get("text") + .and_then(|value| value.as_str()) + .ok_or("sendUserMessage requires args.text")?; + let model = command.args.get("model").and_then(|value| value.as_str()); + let effort = command.args.get("effort").and_then(|value| value.as_str()); + let access_mode = command + .args + .get("accessMode") + .and_then(|value| value.as_str()) + .unwrap_or("current"); + + ensure_workspace_connected(workspace_id, state, app).await?; + let session = { + let sessions = state.sessions.lock().await; + sessions + .get(workspace_id) + .cloned() + .ok_or("workspace not connected")? + }; + + let sandbox_policy = match access_mode { + "full-access" => serde_json::json!({ "type": "dangerFullAccess" }), + "read-only" => serde_json::json!({ "type": "readOnly" }), + _ => serde_json::json!({ + "type": "workspaceWrite", + "writableRoots": [session.entry.path], + "networkAccess": true + }), + }; + let approval_policy = if access_mode == "full-access" { + "never" + } else { + "on-request" + }; + + let params = serde_json::json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": text }], + "cwd": session.entry.path, + "approvalPolicy": approval_policy, + "sandboxPolicy": sandbox_policy, + "model": model, + "effort": effort, + }); + session.send_request("turn/start", params).await?; + + let mut assistant_text: Option = None; + // Poll for a response, publishing snapshots as we go. + for _ in 0..30 { + tokio::time::sleep(Duration::from_millis(2000)).await; + let resume = session + .send_request("thread/resume", serde_json::json!({ "threadId": thread_id })) + .await; + let resume = match resume { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(thread) = extract_thread_from_resume_response(&resume) { + if can_publish { + let _ = publish_thread_snapshot(&container_id, &runner_id, workspace_id, thread_id, thread.clone()).await; + } + assistant_text = latest_agent_text(&thread); + if assistant_text.as_deref().unwrap_or("").trim().len() > 0 { + break; + } + } + } + + Ok(serde_json::json!({ + "submitted": true, + "assistantText": assistant_text, + })) + } + other => Err(format!("Unsupported command type: {other}")), + } + } + + pub(super) async fn command_poller_loop(app: AppHandle) { + debug_log("starting CloudKit poller loop"); + let mut processed: HashSet = HashSet::new(); + let mut processed_order: Vec = Vec::new(); + let mut last_cleanup = Instant::now(); + let mut last_presence = Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or_else(Instant::now); + let mut last_global = Instant::now().checked_sub(Duration::from_secs(60)).unwrap_or_else(Instant::now); + + loop { + let (enabled, container_id, runner_id, poll_ms) = { + let state = app.state::(); + let settings = state.app_settings.lock().await; + ( + settings.cloudkit_enabled, + settings.cloudkit_container_id.clone(), + settings.runner_id.clone(), + settings.cloudkit_poll_interval_ms.unwrap_or(2000), + ) + }; + + if !enabled { + tokio::time::sleep(Duration::from_millis(500)).await; + continue; + } + let container_id = match require_container_id(container_id) { + Ok(value) => value, + Err(_) => { + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + if last_presence.elapsed() > Duration::from_secs(5) { + if let Err(error) = upsert_runner_presence_blocking( + container_id.clone(), + runner_id.clone(), + "CodexMonitor".to_string(), + "macos".to_string(), + ) { + debug_log(&format!("presence upsert failed: {error}")); + } + last_presence = Instant::now(); + } + + if last_global.elapsed() > Duration::from_secs(5) { + let state = app.state::(); + if let Err(error) = publish_global_snapshot(&container_id, &runner_id, &state).await { + debug_log(&format!("global snapshot publish failed: {error}")); + } + last_global = Instant::now(); + } + + let state = app.state::(); + let database = match private_database(&container_id) { + Ok(db) => db, + Err(_) => { + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + let escaped_runner_id = runner_id.replace('\"', "\\\""); + let predicate = format!("runnerId == \"{escaped_runner_id}\" AND status == \"new\""); + let pending: Vec<(String, String)> = { + let records = match perform_query_blocking( + &database, + "CodexMonitorCommand", + &predicate, + Some("createdAtMs"), + true, + ) { + Ok(records) => records, + Err(error) => { + debug_log(&format!("command query failed: {error}")); + Vec::new() + } + }; + + let mut extracted: Vec<(String, String)> = Vec::new(); + for record in records { + // CKRecord isn't Send; extract what we need before awaiting. + let (command_id, payload_json) = match ( + get_string_field(&record, "commandId"), + get_string_field(&record, "payload"), + ) { + (Some(command_id), Some(payload_json)) + if !payload_json.trim().is_empty() => + { + (command_id, payload_json) + } + _ => continue, + }; + extracted.push((command_id, payload_json)); + } + extracted + }; + + if pending.is_empty() { + tokio::time::sleep(Duration::from_millis(poll_ms as u64)).await; + continue; + } + + for (command_id, payload_json) in pending { + let command: IncomingCommand = match serde_json::from_str(&payload_json) { + Ok(value) => value, + Err(_) => continue, + }; + if processed.contains(&command_id) { + // Best-effort cleanup of duplicate commands; delete and skip. + let record_id = record_id_from_name(&command_record_name(&runner_id, &command_id)); + let _ = delete_record_blocking(&database, &record_id); + continue; + } + + processed.insert(command_id.clone()); + processed_order.push(command_id.clone()); + + debug_log(&format!("processing command {command_id} type={}", command.command_type)); + let result = execute_command(command, state.inner(), &app).await; + let (ok, payload) = match result { + Ok(value) => (true, value), + Err(message) => (false, serde_json::json!({ "error": message })), + }; + let payload_json = + serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); + if let Err(error) = write_command_result_blocking( + container_id.clone(), + runner_id.clone(), + command_id.clone(), + ok, + payload_json, + ) { + debug_log(&format!("writing command result failed: {error}")); + } + + let record_id = record_id_from_name(&command_record_name(&runner_id, &command_id)); + if let Err(error) = delete_record_blocking(&database, &record_id) { + debug_log(&format!("deleting command record failed: {error}")); + } + } + + if last_cleanup.elapsed() > Duration::from_secs(60) && processed_order.len() > 1000 { + // Keep memory bounded; CloudKit deletions handle the durable side. + let drain = processed_order.len().saturating_sub(500); + for id in processed_order.drain(0..drain) { + processed.remove(&id); + } + last_cleanup = Instant::now(); + } + + tokio::time::sleep(Duration::from_millis(poll_ms as u64)).await; + } + } + pub(super) fn account_status_blocking(container_id: String) -> Result { let container = container_with_identifier(&container_id)?; let (tx, rx) = std::sync::mpsc::channel::>(); diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 9888cadd0..d4efcdf92 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -15,7 +15,6 @@ use crate::backend::app_server::{ use crate::event_sink::TauriEventSink; use crate::state::AppState; use crate::types::WorkspaceEntry; - pub(crate) async fn spawn_workspace_session( entry: WorkspaceEntry, default_codex_bin: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e04d6fba..7e7d09f14 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,16 @@ mod types; mod utils; mod workspaces; +#[tauri::command] +fn e2e_mark(marker: String) { + eprintln!("[e2e] {marker}"); +} + +#[tauri::command] +fn e2e_quit() { + std::process::exit(0); +} + pub fn cloudkit_cli_status_json(container_id: String) -> Result { let status = cloudkit::cloudkit_cli_status(container_id)?; serde_json::to_string(&status).map_err(|error| error.to_string()) @@ -33,6 +43,51 @@ pub fn cloudkit_cli_test_json(container_id: String) -> Result { serde_json::to_string(&result).map_err(|error| error.to_string()) } +pub fn cloudkit_cli_latest_runner_json(container_id: String) -> Result { + let result = cloudkit::cloudkit_cli_latest_runner(container_id)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_upsert_runner_json(container_id: String, runner_id: String) -> Result { + let result = cloudkit::cloudkit_cli_upsert_runner(container_id, runner_id)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_get_snapshot_json( + container_id: String, + runner_id: String, + scope_key: String, +) -> Result { + let result = cloudkit::cloudkit_cli_get_snapshot(container_id, runner_id, scope_key)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_get_command_result_json( + container_id: String, + runner_id: String, + command_id: String, +) -> Result { + let result = cloudkit::cloudkit_cli_get_command_result(container_id, runner_id, command_id)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_latest_command_result_json( + container_id: String, + runner_id: String, +) -> Result { + let result = cloudkit::cloudkit_cli_latest_command_result(container_id, runner_id)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + +pub fn cloudkit_cli_submit_command_json( + container_id: String, + runner_id: String, + payload_json: String, +) -> Result { + let result = cloudkit::cloudkit_cli_submit_command(container_id, runner_id, payload_json)?; + serde_json::to_string(&result).map_err(|error| error.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[cfg(target_os = "linux")] @@ -153,6 +208,7 @@ pub fn run() { .setup(|app| { let state = state::AppState::load(&app.handle()); app.manage(state); + cloudkit::start_cloudkit_command_poller(app.handle().clone()); #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; @@ -162,10 +218,19 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .invoke_handler(tauri::generate_handler![ + e2e_mark, + e2e_quit, settings::get_app_settings, settings::update_app_settings, cloudkit::cloudkit_status, cloudkit::cloudkit_test, + cloudkit::cloudkit_local_runner_id, + cloudkit::cloudkit_publish_presence, + cloudkit::cloudkit_fetch_latest_runner, + cloudkit::cloudkit_put_snapshot, + cloudkit::cloudkit_get_snapshot, + cloudkit::cloudkit_submit_command, + cloudkit::cloudkit_get_command_result, codex::codex_doctor, workspaces::list_workspaces, workspaces::add_workspace, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 27ae78a65..357c3fb3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -42,6 +42,124 @@ fn main() { } } } + Some("--cloudkit-latest-runner") => { + let container_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() { + eprintln!("Usage: codex-monitor --cloudkit-latest-runner "); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_latest_runner_json(container_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-upsert-runner") => { + let container_id = args.next().unwrap_or_default(); + let runner_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() || runner_id.trim().is_empty() { + eprintln!("Usage: codex-monitor --cloudkit-upsert-runner "); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_upsert_runner_json(container_id, runner_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-get-snapshot") => { + let container_id = args.next().unwrap_or_default(); + let runner_id = args.next().unwrap_or_default(); + let scope_key = args.next().unwrap_or_default(); + if container_id.trim().is_empty() || runner_id.trim().is_empty() || scope_key.trim().is_empty() { + eprintln!( + "Usage: codex-monitor --cloudkit-get-snapshot " + ); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_get_snapshot_json(container_id, runner_id, scope_key) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-get-command-result") => { + let container_id = args.next().unwrap_or_default(); + let runner_id = args.next().unwrap_or_default(); + let command_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() || runner_id.trim().is_empty() || command_id.trim().is_empty() { + eprintln!( + "Usage: codex-monitor --cloudkit-get-command-result " + ); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_get_command_result_json(container_id, runner_id, command_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-latest-command-result") => { + let container_id = args.next().unwrap_or_default(); + let runner_id = args.next().unwrap_or_default(); + if container_id.trim().is_empty() || runner_id.trim().is_empty() { + eprintln!( + "Usage: codex-monitor --cloudkit-latest-command-result " + ); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_latest_command_result_json(container_id, runner_id) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + Some("--cloudkit-submit-command") => { + let container_id = args.next().unwrap_or_default(); + let runner_id = args.next().unwrap_or_default(); + let payload_json = args.next().unwrap_or_default(); + if container_id.trim().is_empty() || runner_id.trim().is_empty() || payload_json.trim().is_empty() { + eprintln!( + "Usage: codex-monitor --cloudkit-submit-command " + ); + std::process::exit(2); + } + match codex_monitor_lib::cloudkit_cli_submit_command_json(container_id, runner_id, payload_json) { + Ok(payload) => { + println!("{payload}"); + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + } + } _ => {} } codex_monitor_lib::run() diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b0d6f426a..ba4136b53 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; +use uuid::Uuid; -use crate::storage::{read_settings, read_workspaces}; +use crate::storage::{read_settings, write_settings, read_workspaces}; use crate::types::{AppSettings, WorkspaceEntry}; pub(crate) struct AppState { @@ -27,7 +28,48 @@ impl AppState { let storage_path = data_dir.join("workspaces.json"); let settings_path = data_dir.join("settings.json"); let workspaces = read_workspaces(&storage_path).unwrap_or_default(); - let app_settings = read_settings(&settings_path).unwrap_or_default(); + let mut app_settings = read_settings(&settings_path).unwrap_or_default(); + + if cfg!(target_os = "ios") { + // iOS is a Cloud client; CloudKit is required to do anything useful. + // Force-enable unless explicitly managed later via Settings UI. + if !app_settings.cloudkit_enabled { + app_settings.cloudkit_enabled = true; + } + } + + if app_settings + .cloudkit_container_id + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + if let Ok(env_container) = std::env::var("CODEXMONITOR_CLOUDKIT_CONTAINER_ID") { + let trimmed = env_container.trim().to_string(); + if !trimmed.is_empty() { + app_settings.cloudkit_container_id = Some(trimmed); + } + } + + // For ILASS builds: default to our CloudKit container on iOS so the app isn't a dead end. + if cfg!(target_os = "ios") + && app_settings + .cloudkit_container_id + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + app_settings.cloudkit_container_id = Some("iCloud.com.ilass.codexmonitor".to_string()); + } + } + if app_settings.runner_id.trim().is_empty() { + app_settings.runner_id = Uuid::new_v4().to_string(); + let _ = write_settings(&settings_path, &app_settings); + } else { + let _ = write_settings(&settings_path, &app_settings); + } Self { workspaces: Mutex::new(workspaces), sessions: Mutex::new(HashMap::new()), diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ab64061b9..a415e15ea 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -128,10 +128,30 @@ pub(crate) struct WorkspaceSettings { pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] pub(crate) codex_bin: Option, + #[serde(default, rename = "runnerId")] + pub(crate) runner_id: String, #[serde(default, rename = "cloudKitEnabled")] pub(crate) cloudkit_enabled: bool, #[serde(default, rename = "cloudKitContainerId")] pub(crate) cloudkit_container_id: Option, + #[serde(default, rename = "cloudKitPollIntervalMs")] + pub(crate) cloudkit_poll_interval_ms: Option, + #[serde(default, rename = "natsEnabled")] + pub(crate) nats_enabled: bool, + #[serde(default, rename = "natsUrl")] + pub(crate) nats_url: Option, + #[serde(default, rename = "natsNamespace")] + pub(crate) nats_namespace: Option, + #[serde(default, rename = "natsCredsFilePath")] + pub(crate) nats_creds_file_path: Option, + #[serde(default, rename = "telegramEnabled")] + pub(crate) telegram_enabled: bool, + #[serde(default, rename = "telegramBotToken")] + pub(crate) telegram_bot_token: Option, + #[serde(default, rename = "telegramAllowedUserIds")] + pub(crate) telegram_allowed_user_ids: Vec, + #[serde(default, rename = "telegramDefaultChatId")] + pub(crate) telegram_default_chat_id: Option, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, #[serde(default = "default_ui_scale", rename = "uiScale")] @@ -159,8 +179,18 @@ impl Default for AppSettings { fn default() -> Self { Self { codex_bin: None, - cloudkit_enabled: false, + runner_id: String::new(), + cloudkit_enabled: cfg!(target_os = "ios"), cloudkit_container_id: None, + cloudkit_poll_interval_ms: None, + nats_enabled: false, + nats_url: None, + nats_namespace: None, + nats_creds_file_path: None, + telegram_enabled: false, + telegram_bot_token: None, + telegram_allowed_user_ids: Vec::new(), + telegram_default_chat_id: None, default_access_mode: "current".to_string(), ui_scale: 1.0, notification_sounds_enabled: true, @@ -176,8 +206,18 @@ mod tests { fn app_settings_defaults_from_empty_json() { let settings: AppSettings = serde_json::from_str("{}").expect("settings deserialize"); assert!(settings.codex_bin.is_none()); + assert!(settings.runner_id.is_empty()); assert!(!settings.cloudkit_enabled); assert!(settings.cloudkit_container_id.is_none()); + assert!(settings.cloudkit_poll_interval_ms.is_none()); + assert!(!settings.nats_enabled); + assert!(settings.nats_url.is_none()); + assert!(settings.nats_namespace.is_none()); + assert!(settings.nats_creds_file_path.is_none()); + assert!(!settings.telegram_enabled); + assert!(settings.telegram_bot_token.is_none()); + assert!(settings.telegram_allowed_user_ids.is_empty()); + assert!(settings.telegram_default_chat_id.is_none()); assert_eq!(settings.default_access_mode, "current"); assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); assert!(settings.notification_sounds_enabled); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index ae3264b59..da23fea73 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -71,6 +71,25 @@ export function SettingsView({ const [cloudKitContainerDraft, setCloudKitContainerDraft] = useState( appSettings.cloudKitContainerId ?? "", ); + const [cloudKitPollDraft, setCloudKitPollDraft] = useState( + appSettings.cloudKitPollIntervalMs ? String(appSettings.cloudKitPollIntervalMs) : "", + ); + const [natsUrlDraft, setNatsUrlDraft] = useState(appSettings.natsUrl ?? ""); + const [natsNamespaceDraft, setNatsNamespaceDraft] = useState( + appSettings.natsNamespace ?? "", + ); + const [natsCredsDraft, setNatsCredsDraft] = useState( + appSettings.natsCredsFilePath ?? "", + ); + const [telegramTokenDraft, setTelegramTokenDraft] = useState( + appSettings.telegramBotToken ?? "", + ); + const [telegramAllowedDraft, setTelegramAllowedDraft] = useState(() => + (appSettings.telegramAllowedUserIds ?? []).join(","), + ); + const [telegramChatDraft, setTelegramChatDraft] = useState( + appSettings.telegramDefaultChatId ? String(appSettings.telegramDefaultChatId) : "", + ); const [overrideDrafts, setOverrideDrafts] = useState>({}); const [doctorState, setDoctorState] = useState<{ status: "idle" | "running" | "done"; @@ -114,6 +133,30 @@ export function SettingsView({ setCloudKitContainerDraft(appSettings.cloudKitContainerId ?? ""); }, [appSettings.cloudKitContainerId]); + useEffect(() => { + setCloudKitPollDraft( + appSettings.cloudKitPollIntervalMs + ? String(appSettings.cloudKitPollIntervalMs) + : "", + ); + }, [appSettings.cloudKitPollIntervalMs]); + + useEffect(() => { + setNatsUrlDraft(appSettings.natsUrl ?? ""); + setNatsNamespaceDraft(appSettings.natsNamespace ?? ""); + setNatsCredsDraft(appSettings.natsCredsFilePath ?? ""); + }, [appSettings.natsUrl, appSettings.natsNamespace, appSettings.natsCredsFilePath]); + + useEffect(() => { + setTelegramTokenDraft(appSettings.telegramBotToken ?? ""); + setTelegramAllowedDraft((appSettings.telegramAllowedUserIds ?? []).join(",")); + setTelegramChatDraft( + appSettings.telegramDefaultChatId + ? String(appSettings.telegramDefaultChatId) + : "", + ); + }, [appSettings.telegramAllowedUserIds, appSettings.telegramBotToken, appSettings.telegramDefaultChatId]); + useEffect(() => { setOverrideDrafts((prev) => { const next: Record = {}; @@ -141,6 +184,10 @@ export function SettingsView({ (appSettings.cloudKitContainerId ?? "").trim(), ); + const cloudKitPollDirty = + (cloudKitPollDraft.trim() || null) !== + (appSettings.cloudKitPollIntervalMs ? String(appSettings.cloudKitPollIntervalMs) : null); + const handleSaveCodexSettings = async () => { setIsSavingSettings(true); try { @@ -251,6 +298,65 @@ export function SettingsView({ } }; + const handleSaveCloudKitPoll = async () => { + if (isSavingCloudSettings) { + return; + } + setIsSavingCloudSettings(true); + try { + const parsed = cloudKitPollDraft.trim() + ? Number.parseInt(cloudKitPollDraft.trim(), 10) + : null; + await onUpdateAppSettings({ + ...appSettings, + cloudKitPollIntervalMs: + parsed && Number.isFinite(parsed) && parsed > 0 ? parsed : null, + }); + } finally { + setIsSavingCloudSettings(false); + } + }; + + const handleBrowseNatsCreds = async () => { + const selection = await open({ multiple: false, directory: false }); + if (!selection || Array.isArray(selection)) { + return; + } + setNatsCredsDraft(selection); + }; + + const handleSaveNatsTelegram = async () => { + if (isSavingCloudSettings) { + return; + } + setIsSavingCloudSettings(true); + try { + const allowedIds = telegramAllowedDraft + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => Number.parseInt(entry, 10)) + .filter((entry) => Number.isFinite(entry)); + const defaultChatId = telegramChatDraft.trim() + ? Number.parseInt(telegramChatDraft.trim(), 10) + : null; + await onUpdateAppSettings({ + ...appSettings, + natsUrl: natsUrlDraft.trim() ? natsUrlDraft.trim() : null, + natsNamespace: natsNamespaceDraft.trim() ? natsNamespaceDraft.trim() : null, + natsCredsFilePath: natsCredsDraft.trim() ? natsCredsDraft.trim() : null, + telegramBotToken: telegramTokenDraft.trim() + ? telegramTokenDraft.trim() + : null, + telegramAllowedUserIds: allowedIds, + telegramDefaultChatId: + defaultChatId && Number.isFinite(defaultChatId) ? defaultChatId : null, + }); + } finally { + setIsSavingCloudSettings(false); + } + }; + const handleRunCloudStatus = async () => { setCloudStatusState({ status: "running", result: null, error: null }); try { @@ -491,6 +597,12 @@ export function SettingsView({
Optional iCloud sync for projects and chats.
+
+ +
+ {appSettings.runnerId || "…"} +
+
Enable CloudKit Sync
@@ -533,6 +645,25 @@ export function SettingsView({
+
+ +
+ setCloudKitPollDraft(event.target.value)} + inputMode="numeric" + /> +
+
+ Leave empty to use the default. Lower values mean faster remote control but more CloudKit traffic. +
+
+
{cloudKitContainerDirty && ( + )}
)} + +
+ +
NATS (Realtime)
+
+ Optional low-latency transport for streaming events and commands. +
+
+
+
Enable NATS
+
+ Requires a reachable NATS server (self-hosted or managed). +
+
+ +
+
+ +
+ setNatsUrlDraft(event.target.value)} + /> +
+
+
+ +
+ setNatsNamespaceDraft(event.target.value)} + /> +
+
+
+ +
+ setNatsCredsDraft(event.target.value)} + /> + +
+
+ +
+ +
Telegram (Notifications)
+
+ Minimal remote control via a Telegram bot (commands + main events). +
+
+
+
Enable Telegram
+
+ Requires a bot token and an allowlist. +
+
+ +
+
+ +
+ setTelegramTokenDraft(event.target.value)} + /> +
+
+
+ +
+ setTelegramAllowedDraft(event.target.value)} + /> +
+
+
+ +
+ setTelegramChatDraft(event.target.value)} + /> +
+
+
+ +
)} {activeSection === "codex" && ( diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 55de6a45d..f58cf0f75 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -2,15 +2,30 @@ import { useCallback, useEffect, useState } from "react"; import type { AppSettings } from "../../../types"; import { getAppSettings, runCodexDoctor, updateAppSettings } from "../../../services/tauri"; import { clampUiScale, UI_SCALE_DEFAULT } from "../../../utils/uiScale"; +import { isAppleMobile } from "../../../utils/platform"; -const defaultSettings: AppSettings = { - codexBin: null, - cloudKitEnabled: false, - cloudKitContainerId: null, - defaultAccessMode: "current", - uiScale: UI_SCALE_DEFAULT, - notificationSoundsEnabled: true, -}; +function buildDefaultSettings(): AppSettings { + // On iOS/iPadOS, the app is effectively a Cloud client. Default CloudKit to ON so + // first launch can immediately check for a running Mac runner. + const cloudDefault = isAppleMobile(); + return { + codexBin: null, + runnerId: "", + cloudKitEnabled: cloudDefault, + cloudKitContainerId: null, + cloudKitPollIntervalMs: null, + natsEnabled: false, + natsUrl: null, + natsNamespace: null, + natsCredsFilePath: null, + telegramEnabled: false, + telegramBotToken: null, + telegramAllowedUserIds: [], + telegramDefaultChatId: null, + defaultAccessMode: "current", + uiScale: UI_SCALE_DEFAULT, + }; +} function normalizeAppSettings(settings: AppSettings): AppSettings { return { @@ -20,7 +35,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { } export function useAppSettings() { - const [settings, setSettings] = useState(defaultSettings); + const [settings, setSettings] = useState(() => buildDefaultSettings()); const [isLoading, setIsLoading] = useState(true); useEffect(() => { @@ -29,9 +44,10 @@ export function useAppSettings() { try { const response = await getAppSettings(); if (active) { + const defaults = buildDefaultSettings(); setSettings( normalizeAppSettings({ - ...defaultSettings, + ...defaults, ...response, }), ); @@ -48,11 +64,15 @@ export function useAppSettings() { }, []); const saveSettings = useCallback(async (next: AppSettings) => { - const normalized = normalizeAppSettings(next); + const defaults = buildDefaultSettings(); + const normalized = normalizeAppSettings({ + ...defaults, + ...next, + }); const saved = await updateAppSettings(normalized); setSettings( normalizeAppSettings({ - ...defaultSettings, + ...defaults, ...saved, }), ); diff --git a/src/services/tauri.ts b/src/services/tauri.ts index adcdcc4dd..b80c64089 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -2,6 +2,10 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { AppSettings, + CloudKitCommandAck, + CloudKitCommandResult, + CloudKitRunnerInfo, + CloudKitSnapshot, CloudKitStatus, CloudKitTestResult, CodexDoctorResult, @@ -199,6 +203,14 @@ export async function updateAppSettings(settings: AppSettings): Promise("update_app_settings", { settings }); } +export async function e2eMark(marker: string): Promise { + return invoke("e2e_mark", { marker }); +} + +export async function e2eQuit(): Promise { + return invoke("e2e_quit"); +} + export async function runCodexDoctor( codexBin: string | null, ): Promise { @@ -213,6 +225,46 @@ export async function cloudkitTest(): Promise { return invoke("cloudkit_test"); } +export async function cloudkitLocalRunnerId(): Promise { + return invoke("cloudkit_local_runner_id"); +} + +export async function cloudkitPublishPresence( + name: string, + platform: string, +): Promise { + return invoke("cloudkit_publish_presence", { name, platform }); +} + +export async function cloudkitFetchLatestRunner(): Promise { + return invoke("cloudkit_fetch_latest_runner"); +} + +export async function cloudkitPutSnapshot(scopeKey: string, payloadJson: string): Promise { + return invoke("cloudkit_put_snapshot", { scopeKey, payloadJson }); +} + +export async function cloudkitGetSnapshot( + runnerId: string, + scopeKey: string, +): Promise { + return invoke("cloudkit_get_snapshot", { runnerId, scopeKey }); +} + +export async function cloudkitSubmitCommand( + runnerId: string, + payloadJson: string, +): Promise { + return invoke("cloudkit_submit_command", { runnerId, payloadJson }); +} + +export async function cloudkitGetCommandResult( + runnerId: string, + commandId: string, +): Promise { + return invoke("cloudkit_get_command_result", { runnerId, commandId }); +} + export async function getWorkspaceFiles(workspaceId: string) { return invoke("list_workspace_files", { workspaceId }); } diff --git a/src/styles/settings.css b/src/styles/settings.css index c36bb945c..7caa076d6 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -55,6 +55,8 @@ .settings-close { padding: 4px; + color: var(--text-strong); + border-color: var(--border-muted); } .settings-body { diff --git a/src/types.ts b/src/types.ts index 65a025c56..a30a59621 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,8 +63,18 @@ export type AccessMode = "read-only" | "current" | "full-access"; export type AppSettings = { codexBin: string | null; + runnerId: string; cloudKitEnabled: boolean; cloudKitContainerId: string | null; + cloudKitPollIntervalMs: number | null; + natsEnabled: boolean; + natsUrl: string | null; + natsNamespace: string | null; + natsCredsFilePath: string | null; + telegramEnabled: boolean; + telegramBotToken: string | null; + telegramAllowedUserIds: number[]; + telegramDefaultChatId: number | null; defaultAccessMode: AccessMode; uiScale: number; notificationSoundsEnabled: boolean; @@ -92,6 +102,30 @@ export type CloudKitTestResult = { durationMs: number; }; +export type CloudKitRunnerInfo = { + runnerId: string; + name: string; + platform: string; + updatedAtMs: number; +}; + +export type CloudKitSnapshot = { + scopeKey: string; + updatedAtMs: number; + payloadJson: string; +}; + +export type CloudKitCommandAck = { + commandId: string; +}; + +export type CloudKitCommandResult = { + commandId: string; + ok: boolean; + createdAtMs: number; + payloadJson: string; +}; + export type ApprovalRequest = { workspace_id: string; request_id: number; From cb0381825b399b5e76cc82a6d9630bbc9d7f6510 Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Wed, 14 Jan 2026 06:00:17 +0100 Subject: [PATCH 07/20] feat(ios): add CloudKit client UI + e2e scripts - Add iOS/iPadOS Cloud client app that polls runner/snapshots and submits commands. - Add end-to-end scripts for simulator/device, including screenshot capture. - Suppress updater errors on platforms without updater permissions. --- scripts/ios-build-device.sh | 38 ++ scripts/ios-e2e-joke-device.sh | 114 ++++ scripts/ios-e2e-joke-sim.sh | 40 ++ scripts/ios-run-sim.sh | 10 +- src/App.tsx | 5 + src/cloud/cloudTypes.ts | 68 +++ src/cloud/transport.ts | 18 + .../app/components/CloudClientApp.tsx | 516 ++++++++++++++++++ src/features/update/hooks/useUpdater.ts | 19 + src/styles/cloud-client.css | 126 +++++ src/threads/threadItems.ts | 206 +++++++ 11 files changed, 1158 insertions(+), 2 deletions(-) create mode 100755 scripts/ios-build-device.sh create mode 100755 scripts/ios-e2e-joke-device.sh create mode 100755 scripts/ios-e2e-joke-sim.sh create mode 100644 src/cloud/cloudTypes.ts create mode 100644 src/cloud/transport.ts create mode 100644 src/features/app/components/CloudClientApp.tsx create mode 100644 src/styles/cloud-client.css create mode 100644 src/threads/threadItems.ts diff --git a/scripts/ios-build-device.sh b/scripts/ios-build-device.sh new file mode 100755 index 000000000..b99aa0211 --- /dev/null +++ b/scripts/ios-build-device.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Ensure we don't accidentally pick up an older MacPorts cargo (e.g. /opt/local/bin/cargo). +NODE_BIN="$(command -v node)" +NODE_DIR="$(cd "$(dirname "${NODE_BIN}")" && pwd)" + +export PATH="${ROOT_DIR}/node_modules/.bin:${HOME}/.cargo/bin:${NODE_DIR}:/usr/bin:/bin:/usr/sbin:/sbin" + +cd "${ROOT_DIR}" + +echo "[ios] Using cargo: $(command -v cargo)" +cargo -V + +rm -rf "${ROOT_DIR}/src-tauri/gen/apple/build" + +echo "[ios] Building (device)..." +npm run tauri -- ios build -d -t aarch64 --ci + +IPA_PATH="${ROOT_DIR}/src-tauri/gen/apple/build/arm64/CodexMonitor.ipa" +if [[ -f "${IPA_PATH}" ]]; then + echo "[ios] Extracting .app from ${IPA_PATH}..." + TMP_DIR="${ROOT_DIR}/src-tauri/gen/apple/build/arm64/_ipa_extract" + rm -rf "${TMP_DIR}" + mkdir -p "${TMP_DIR}" + unzip -q "${IPA_PATH}" -d "${TMP_DIR}" + APP_IN_PAYLOAD="${TMP_DIR}/Payload/CodexMonitor.app" + OUT_APP="${ROOT_DIR}/src-tauri/gen/apple/build/arm64/CodexMonitor.app" + rm -rf "${OUT_APP}" + if [[ -d "${APP_IN_PAYLOAD}" ]]; then + cp -R "${APP_IN_PAYLOAD}" "${OUT_APP}" + echo "[ios] Extracted app bundle to: ${OUT_APP}" + else + echo "[ios] Warning: could not find Payload/CodexMonitor.app inside ipa." >&2 + fi +fi diff --git a/scripts/ios-e2e-joke-device.sh b/scripts/ios-e2e-joke-device.sh new file mode 100755 index 000000000..2f8bb379d --- /dev/null +++ b/scripts/ios-e2e-joke-device.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +DEVICE="${1:-iPad von Peter (2)}" +CONTAINER_ID="${CODEXMONITOR_CLOUDKIT_CONTAINER_ID:-iCloud.com.ilass.codexmonitor}" +BUNDLE_ID="${BUNDLE_ID:-com.ilass.codexmonitor}" +APP_BIN="${APP_BIN:-${ROOT_DIR}/src-tauri/target/release/bundle/macos/CodexMonitor.app/Contents/MacOS/codex-monitor}" + +OUT_DIR="${ROOT_DIR}/.run/ios/device" +mkdir -p "${OUT_DIR}" + +export VITE_E2E=1 +export CODEXMONITOR_CLOUDKIT_CONTAINER_ID="${CONTAINER_ID}" + +echo "[ios-e2e-device] Building + installing with VITE_E2E=1..." +scripts/ios-build-device.sh + +APP_PATH="${ROOT_DIR}/src-tauri/gen/apple/build/arm64/CodexMonitor.app" +if [[ ! -d "${APP_PATH}" ]]; then + echo "[ios-e2e-device] Expected app bundle not found at: ${APP_PATH}" >&2 + exit 1 +fi + +echo "[ios-e2e-device] Installing to device: ${DEVICE}" +xcrun devicectl device install app --device "${DEVICE}" "${APP_PATH}" + +STAMP="$(date +%Y%m%d-%H%M%S)" +LOG_PATH="${OUT_DIR}/e2e-${STAMP}.log" +echo "[ios-e2e-device] Launching ${BUNDLE_ID} on ${DEVICE}..." + +START_MS="$(python3 -c 'import time; print(int(time.time()*1000))')" + +/usr/bin/env DEVICECTL_CHILD_CODEXMONITOR_CLOUDKIT_CONTAINER_ID="${CONTAINER_ID}" \ + xcrun devicectl device process launch \ + --device "${DEVICE}" \ + --terminate-existing \ + --activate \ + "${BUNDLE_ID}" > "${LOG_PATH}" 2>&1 || true + +echo "[ios-e2e-device] Launch output:" +tail -n 120 "${LOG_PATH}" || true + +if [[ ! -x "${APP_BIN}" ]]; then + echo "[ios-e2e-device] Error: macOS app binary not found/executable at: ${APP_BIN}" >&2 + exit 1 +fi + +echo "[ios-e2e-device] Waiting for CloudKit command result (this verifies iPad -> CloudKit -> Mac runner -> Codex -> CloudKit)..." + +DEADLINE_MS=$((START_MS + 180000)) +while true; do + NOW_MS="$(python3 -c 'import time; print(int(time.time()*1000))')" + if (( NOW_MS > DEADLINE_MS )); then + echo "[ios-e2e-device] Timed out waiting for CloudKit command result." >&2 + exit 1 + fi + + RUNNER_JSON="$("${APP_BIN}" --cloudkit-latest-runner "${CONTAINER_ID}" 2>/dev/null || true)" + RUNNER_ID="$(python3 - <<'PY' "${RUNNER_JSON}" +import json,sys +raw=sys.argv[1].strip() +if not raw: + sys.exit(0) +try: + data=json.loads(raw) + print(data.get("runnerId","")) +except Exception: + pass +PY +)" + if [[ -z "${RUNNER_ID}" ]]; then + sleep 2 + continue + fi + + RES_JSON="$("${APP_BIN}" --cloudkit-latest-command-result "${CONTAINER_ID}" "${RUNNER_ID}" 2>/dev/null || true)" + OK="$(python3 - <<'PY' "${RES_JSON}" "${START_MS}" +import json,sys +raw=sys.argv[1].strip() +start=int(sys.argv[2]) +if not raw: + sys.exit(0) +try: + data=json.loads(raw) +except Exception: + sys.exit(0) +if not data: + sys.exit(0) +created=int(data.get("createdAtMs") or 0) +if created < start: + sys.exit(0) +if not data.get("ok"): + sys.exit(0) +payload=data.get("payloadJson") or "" +try: + inner=json.loads(payload) +except Exception: + inner={} +assistant=(inner.get("assistantText") or "").strip() +if not assistant: + sys.exit(0) +print("1") +PY +)" + if [[ "${OK}" == "1" ]]; then + echo "[ios-e2e-device] E2E SUCCESS" + exit 0 + fi + + sleep 2 +done diff --git a/scripts/ios-e2e-joke-sim.sh b/scripts/ios-e2e-joke-sim.sh new file mode 100755 index 000000000..33108a12d --- /dev/null +++ b/scripts/ios-e2e-joke-sim.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "${ROOT_DIR}" + +UDID="${1:-}" +if [[ -z "${UDID}" ]]; then + UDID="$(xcrun simctl list devices booted | rg -o '[0-9A-Fa-f-]{36}' | head -n 1 || true)" +fi + +if [[ -z "${UDID}" ]]; then + echo "[ios-e2e] No booted simulator found." + echo "[ios-e2e] Boot one first (e.g. via Simulator.app), then rerun:" + echo " scripts/ios-e2e-joke-sim.sh" + exit 1 +fi + +OUT_DIR="${ROOT_DIR}/.run/ios" +mkdir -p "${OUT_DIR}" + +export VITE_E2E=1 + +if [[ -z "${CODEXMONITOR_CLOUDKIT_CONTAINER_ID:-}" ]]; then + echo "[ios-e2e] CODEXMONITOR_CLOUDKIT_CONTAINER_ID is not set." + echo "[ios-e2e] CloudKit access will likely fail without a container identifier." +fi + +echo "[ios-e2e] Building + launching with VITE_E2E=1..." +scripts/ios-run-sim.sh "${UDID}" + +echo "[ios-e2e] Waiting for CloudKit command/response..." +sleep 25 + +STAMP="$(date +%Y%m%d-%H%M%S)" +SCREENSHOT_PATH="${OUT_DIR}/e2e-joke-${STAMP}.png" +echo "[ios-e2e] Taking screenshot: ${SCREENSHOT_PATH}" +xcrun simctl io "${UDID}" screenshot "${SCREENSHOT_PATH}" + +echo "[ios-e2e] Done." diff --git a/scripts/ios-run-sim.sh b/scripts/ios-run-sim.sh index 54eb5d915..18c3ff56d 100755 --- a/scripts/ios-run-sim.sh +++ b/scripts/ios-run-sim.sh @@ -32,7 +32,14 @@ STDOUT_LOG="${OUT_DIR}/app-stdout.log" STDERR_LOG="${OUT_DIR}/app-stderr.log" echo "[ios] Launching ${BUNDLE_ID} (logs: ${STDOUT_LOG}, ${STDERR_LOG})..." -xcrun simctl launch --terminate-running-process \ +if [[ -n "${CODEXMONITOR_CLOUDKIT_CONTAINER_ID:-}" ]]; then + echo "[ios] Setting CODEXMONITOR_CLOUDKIT_CONTAINER_ID for simulator runtime..." + xcrun simctl spawn "${UDID}" launchctl setenv \ + CODEXMONITOR_CLOUDKIT_CONTAINER_ID "${CODEXMONITOR_CLOUDKIT_CONTAINER_ID}" +fi + +xcrun simctl launch \ + --terminate-running-process \ --stdout="${STDOUT_LOG}" \ --stderr="${STDERR_LOG}" \ "${UDID}" "${BUNDLE_ID}" @@ -45,4 +52,3 @@ echo "[ios] Taking screenshot: ${SCREENSHOT_PATH}" xcrun simctl io "${UDID}" screenshot "${SCREENSHOT_PATH}" echo "[ios] Done." - diff --git a/src/App.tsx b/src/App.tsx index 29ee5a3f1..4754835ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import "./styles/about.css"; import "./styles/tabbar.css"; import "./styles/worktree-modal.css"; import "./styles/settings.css"; +import "./styles/cloud-client.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; import "./styles/compact-tablet.css"; @@ -64,6 +65,7 @@ import { useTerminalController } from "./features/terminal/hooks/useTerminalCont import { cloudkitStatus, cloudkitTest } from "./services/tauri"; import { playNotificationSound } from "./utils/notificationSounds"; import { isAppleMobile } from "./utils/platform"; +import { CloudClientApp } from "./features/app/components/CloudClientApp"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -980,6 +982,9 @@ function App() { if (windowLabel === "about") { return ; } + if (isAppleMobile()) { + return ; + } return ; } diff --git a/src/cloud/cloudTypes.ts b/src/cloud/cloudTypes.ts new file mode 100644 index 000000000..ddcc41167 --- /dev/null +++ b/src/cloud/cloudTypes.ts @@ -0,0 +1,68 @@ +import type { + ConversationItem, + ThreadSummary, + WorkspaceInfo, +} from "../types"; + +export type CloudScopeKey = string; + +export function globalScopeKey(): CloudScopeKey { + return "g"; +} + +export function workspaceScopeKey(workspaceId: string): CloudScopeKey { + return `ws/${workspaceId}`; +} + +export function threadScopeKey(workspaceId: string, threadId: string): CloudScopeKey { + return `th/${workspaceId}/${threadId}`; +} + +export type CloudSnapshotEnvelope = { + v: 1; + ts: number; + runnerId: string; + scopeKey: CloudScopeKey; + payload: T; +}; + +export type CloudThreadStatus = { + isProcessing: boolean; + hasUnread: boolean; + isReviewing: boolean; +}; + +export type CloudGlobalSnapshot = CloudSnapshotEnvelope<{ + workspaces: WorkspaceInfo[]; +}>; + +export type CloudWorkspaceSnapshot = CloudSnapshotEnvelope<{ + workspaceId: string; + threads: ThreadSummary[]; + threadStatusById: Record; +}>; + +export type CloudThreadSnapshot = CloudSnapshotEnvelope<{ + workspaceId: string; + threadId: string; + // Desktop runner may publish pre-rendered items. The backend publisher can instead + // publish the raw thread record and let the iOS client rebuild items. + items?: ConversationItem[] | null; + thread?: Record | null; + status: CloudThreadStatus | null; +}>; + +export function parseCloudSnapshot(payloadJson: string): CloudSnapshotEnvelope | null { + try { + const parsed = JSON.parse(payloadJson) as CloudSnapshotEnvelope; + if (!parsed || typeof parsed !== "object") { + return null; + } + if ((parsed as any).v !== 1) { + return null; + } + return parsed; + } catch { + return null; + } +} diff --git a/src/cloud/transport.ts b/src/cloud/transport.ts new file mode 100644 index 000000000..fd5213e78 --- /dev/null +++ b/src/cloud/transport.ts @@ -0,0 +1,18 @@ +export type TransportKind = "local" | "cloudkit" | "nats" | "telegram"; + +export type TransportCaps = { + realtime: boolean; + snapshots: boolean; + commands: boolean; +}; + +export type TransportStatus = + | { ok: true; label: string } + | { ok: false; label: string; detail?: string }; + +export interface Transport { + kind: TransportKind; + caps(): TransportCaps; + status(): Promise; +} + diff --git a/src/features/app/components/CloudClientApp.tsx b/src/features/app/components/CloudClientApp.tsx new file mode 100644 index 000000000..990dfba26 --- /dev/null +++ b/src/features/app/components/CloudClientApp.tsx @@ -0,0 +1,516 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Settings } from "lucide-react"; +import type { ConversationItem, WorkspaceInfo } from "../../../types"; +import { + cloudkitStatus, + cloudkitTest, + cloudkitFetchLatestRunner, + cloudkitGetCommandResult, + cloudkitGetSnapshot, + cloudkitSubmitCommand, + e2eMark, + e2eQuit, +} from "../../../services/tauri"; +import { + globalScopeKey, + parseCloudSnapshot, + threadScopeKey, + workspaceScopeKey, + type CloudGlobalSnapshot, + type CloudThreadSnapshot, + type CloudWorkspaceSnapshot, +} from "../../../cloud/cloudTypes"; +import { Messages } from "../../messages/components/Messages"; +import { TabBar } from "./TabBar"; +import { SettingsView } from "../../settings/components/SettingsView"; +import { useAppSettings } from "../../settings/hooks/useAppSettings"; +import { buildItemsFromThread } from "../../../threads/threadItems"; + +function ensureClientId() { + try { + const existing = window.localStorage.getItem("cloudClientId"); + if (existing && existing.trim()) { + return existing; + } + const next = crypto.randomUUID(); + window.localStorage.setItem("cloudClientId", next); + return next; + } catch { + return "ios-client"; + } +} + +function isRunnerOnline(updatedAtMs: number) { + return Date.now() - updatedAtMs < 20_000; +} + +type PendingCommand = { + id: string; + createdAt: number; + status: "submitting" | "waiting" | "done" | "error"; + error?: string; +}; + +export function CloudClientApp() { + const { settings: appSettings, saveSettings, doctor } = useAppSettings(); + // iOS/iPadOS build: always operate in Cloud mode. + const cloudEnabled = true; + const clientId = useMemo(() => ensureClientId(), []); + const [activeTab, setActiveTab] = useState<"projects" | "codex" | "git" | "log">( + "projects", + ); + const [runnerId, setRunnerId] = useState(null); + const [runnerLabel, setRunnerLabel] = useState(null); + const [runnerOnline, setRunnerOnline] = useState(false); + const [global, setGlobal] = useState(null); + const [workspaceSnap, setWorkspaceSnap] = useState(null); + const [threadSnap, setThreadSnap] = useState(null); + const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); + const [activeThreadId, setActiveThreadId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [draft, setDraft] = useState(""); + const [pendingCommand, setPendingCommand] = useState(null); + const lastThreadUpdatedAt = useRef(0); + const [cloudError, setCloudError] = useState(null); + const e2eThreadRequested = useRef(false); + const e2eBaseline = useRef<{ assistantCount: number } | null>(null); + const e2eCompleted = useRef(false); + + const submitCommand = useCallback( + async (type: string, args: Record) => { + if (!cloudEnabled || !runnerId) { + return null; + } + const commandId = crypto.randomUUID(); + await cloudkitSubmitCommand( + runnerId, + JSON.stringify({ commandId, clientId, type, args }), + ); + return commandId; + }, + [clientId, cloudEnabled, runnerId], + ); + + useEffect(() => { + if (!cloudEnabled) { + setRunnerId(null); + setRunnerLabel(null); + setRunnerOnline(false); + setGlobal(null); + setWorkspaceSnap(null); + setThreadSnap(null); + return; + } + + let stopped = false; + + const tick = async () => { + if (stopped) return; + try { + setCloudError(null); + const runner = await cloudkitFetchLatestRunner(); + if (!runner) { + setRunnerId(null); + setRunnerLabel(null); + setRunnerOnline(false); + setGlobal(null); + return; + } + setRunnerId(runner.runnerId); + setRunnerLabel(`${runner.name} (${runner.platform})`); + setRunnerOnline(isRunnerOnline(runner.updatedAtMs)); + + const globalRecord = await cloudkitGetSnapshot(runner.runnerId, globalScopeKey()); + if (globalRecord?.payloadJson) { + const parsed = parseCloudSnapshot(globalRecord.payloadJson); + if (parsed) { + setGlobal(parsed as CloudGlobalSnapshot); + } + } + + if (activeWorkspaceId) { + const wsRecord = await cloudkitGetSnapshot(runner.runnerId, workspaceScopeKey(activeWorkspaceId)); + if (wsRecord?.payloadJson) { + const parsed = parseCloudSnapshot(wsRecord.payloadJson); + if (parsed) { + setWorkspaceSnap(parsed as CloudWorkspaceSnapshot); + } + } + } else { + setWorkspaceSnap(null); + } + + if (activeWorkspaceId && activeThreadId) { + const thRecord = await cloudkitGetSnapshot( + runner.runnerId, + threadScopeKey(activeWorkspaceId, activeThreadId), + ); + if (thRecord?.payloadJson) { + const parsed = parseCloudSnapshot(thRecord.payloadJson); + if (parsed) { + const next = parsed as CloudThreadSnapshot; + if (next.ts > lastThreadUpdatedAt.current) { + lastThreadUpdatedAt.current = next.ts; + setThreadSnap(next); + } + } + } + } else { + setThreadSnap(null); + } + } catch { + // ignore; we'll retry on next tick + } + }; + + void tick(); + const interval = window.setInterval(() => void tick(), 2500); + return () => { + stopped = true; + window.clearInterval(interval); + }; + }, [activeThreadId, activeWorkspaceId, cloudEnabled]); + + useEffect(() => { + if (!cloudEnabled) { + return; + } + let active = true; + void (async () => { + try { + await cloudkitStatus(); + } catch (error) { + if (!active) return; + setCloudError(error instanceof Error ? error.message : String(error)); + } + })(); + return () => { + active = false; + }; + }, [cloudEnabled]); + + const workspaces: WorkspaceInfo[] = global?.payload.workspaces ?? []; + + const activeWorkspace = useMemo( + () => workspaces.find((ws) => ws.id === activeWorkspaceId) ?? null, + [activeWorkspaceId, workspaces], + ); + + const threads = workspaceSnap?.payload.workspaceId === activeWorkspaceId + ? workspaceSnap.payload.threads + : []; + + const activeItems: ConversationItem[] = useMemo(() => { + if (!threadSnap || threadSnap.payload.threadId !== activeThreadId) { + return []; + } + const items = Array.isArray(threadSnap.payload.items) ? threadSnap.payload.items : null; + if (items && items.length) { + return items; + } + const thread = threadSnap.payload.thread as Record | null | undefined; + if (thread && typeof thread === "object") { + return buildItemsFromThread(thread); + } + return []; + }, [activeThreadId, threadSnap]); + + const canSend = Boolean( + cloudEnabled && runnerId && runnerOnline && activeWorkspaceId && activeThreadId, + ); + + const handleSelectWorkspace = useCallback( + (id: string) => { + setActiveWorkspaceId(id); + setActiveThreadId(null); + setActiveTab("codex"); + if (runnerId && cloudEnabled) { + void submitCommand("connectWorkspace", { workspaceId: id }); + } + }, + [cloudEnabled, runnerId, submitCommand], + ); + + const handleSelectThread = useCallback( + (threadId: string) => { + setActiveThreadId(threadId); + setActiveTab("codex"); + if (runnerId && cloudEnabled && activeWorkspaceId) { + void submitCommand("resumeThread", { workspaceId: activeWorkspaceId, threadId }); + } + }, + [activeWorkspaceId, cloudEnabled, runnerId, submitCommand], + ); + + const handleSend = useCallback(async (overrideText?: string) => { + if (!canSend || !runnerId || !activeWorkspaceId || !activeThreadId) { + return; + } + const text = (overrideText ?? draft).trim(); + if (!text) return; + setDraft(""); + + const commandId = crypto.randomUUID(); + setPendingCommand({ id: commandId, createdAt: Date.now(), status: "submitting" }); + try { + await cloudkitSubmitCommand( + runnerId, + JSON.stringify({ + commandId, + clientId, + type: "sendUserMessage", + args: { + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + text, + accessMode: "current", + }, + }), + ); + setPendingCommand((prev) => (prev ? { ...prev, status: "waiting" } : prev)); + } catch (error) { + setPendingCommand({ + id: commandId, + createdAt: Date.now(), + status: "error", + error: error instanceof Error ? error.message : String(error), + }); + } + }, [activeThreadId, activeWorkspaceId, canSend, clientId, draft, runnerId]); + + useEffect(() => { + if (!pendingCommand || pendingCommand.status !== "waiting" || !runnerId) { + return; + } + let stopped = false; + const interval = window.setInterval(() => { + void (async () => { + if (stopped) return; + const result = await cloudkitGetCommandResult(runnerId, pendingCommand.id); + if (!result) return; + setPendingCommand((prev) => { + if (!prev || prev.id !== pendingCommand.id) return prev; + return result.ok + ? { ...prev, status: "done" } + : { ...prev, status: "error", error: result.payloadJson || "Command failed" }; + }); + })(); + }, 1500); + return () => { + stopped = true; + window.clearInterval(interval); + }; + }, [pendingCommand, runnerId]); + + const e2eEnabled = (import.meta as any).env?.VITE_E2E === "1"; + useEffect(() => { + if (!e2eEnabled || !cloudEnabled || !runnerId || !runnerOnline || !workspaces.length) { + return; + } + // One-shot E2E: select first workspace/thread and send a joke prompt. + if (activeWorkspaceId && activeThreadId) { + return; + } + const ws = workspaces[0]; + handleSelectWorkspace(ws.id); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, handleSelectWorkspace, runnerId, runnerOnline, workspaces]); + + useEffect(() => { + if (!e2eEnabled || !cloudEnabled || !runnerOnline) return; + if (!activeWorkspaceId) return; + if (!threads.length) return; + if (activeThreadId) return; + handleSelectThread(threads[0].id); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, handleSelectThread, runnerOnline, threads]); + + useEffect(() => { + if (!e2eEnabled || !cloudEnabled || !runnerOnline) return; + if (!activeWorkspaceId) return; + if (activeThreadId) return; + if (threads.length) return; + if (pendingCommand) return; + if (e2eThreadRequested.current) return; + + e2eThreadRequested.current = true; + window.setTimeout(() => { + if (!runnerOnline || !runnerId) return; + void submitCommand("startThread", { workspaceId: activeWorkspaceId }); + }, 1500); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, pendingCommand, runnerId, runnerOnline, submitCommand, threads.length]); + + useEffect(() => { + if (!e2eEnabled || !cloudEnabled || !runnerOnline) return; + if (!activeWorkspaceId || !activeThreadId) return; + if (pendingCommand) return; + if (!e2eBaseline.current) { + e2eBaseline.current = { + assistantCount: activeItems.filter( + (item) => item.kind === "message" && item.role === "assistant", + ).length, + }; + } + void handleSend("Erzähl mir einen kurzen Witz."); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, handleSend, pendingCommand, runnerOnline]); + + useEffect(() => { + if (!e2eEnabled || e2eCompleted.current) return; + if (!e2eBaseline.current) return; + if (!pendingCommand || pendingCommand.status !== "done") return; + + const currentAssistantCount = activeItems.filter( + (item) => item.kind === "message" && item.role === "assistant", + ).length; + if (currentAssistantCount <= e2eBaseline.current.assistantCount) return; + + e2eCompleted.current = true; + void e2eMark("success: received assistant response"); + window.setTimeout(() => void e2eQuit(), 750); + }, [activeItems, e2eEnabled, pendingCommand]); + + const headerHint = cloudError + ? cloudError + : !runnerId + ? "Waiting for a running CodexMonitor on your iCloud…" + : !runnerOnline + ? "CodexMonitor on Mac seems offline. Start it to sync projects." + : null; + + const showProjects = activeTab === "projects"; + const showChat = activeTab === "codex"; + const showPlaceholder = !showProjects && !showChat; + + return ( +
+
+
{showProjects ? "Projects" : activeWorkspace?.name ?? "Codex"}
+ +
+ + {headerHint &&
{headerHint}
} + {runnerLabel && ( +
+ {runnerLabel} · {runnerOnline ? "online" : "offline"} +
+ )} + + {showProjects && ( +
+ {workspaces.length === 0 ? ( +
No workspaces yet.
+ ) : ( + workspaces.map((ws) => ( + + )) + )} +
+ )} + + {showChat && ( +
+
+ {threads.length === 0 ? ( +
No agents yet.
+ ) : ( + threads.map((thread) => ( + + )) + )} +
+
+ +
+
+ +
+
+ {pendingCommand?.status === "error" && ( +
+ {pendingCommand.error ?? "Command failed."} +
+ )} +