diff --git a/.gitignore b/.gitignore index a757a9427..8bb8364f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Logs logs *.log +*.pid npm-debug.log* yarn-debug.log* yarn-error.log* @@ -25,3 +26,9 @@ 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 +.run/ diff --git a/package-lock.json b/package-lock.json index dc2e07e96..bfc573459 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.6.7", "dependencies": { "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", @@ -1391,9 +1391,9 @@ } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.5.0.tgz", - "integrity": "sha512-I0R0ygwRd9AN8Wj5GnzCogOlqu2+OWAtBd0zEC4+kQCI32fRowIyuhPCBoUv4h/lQt2bM39kHlxPHD5vDcFjiA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", "license": "MIT OR Apache-2.0", "dependencies": { "@tauri-apps/api": "^2.8.0" diff --git a/package.json b/package.json index 81fc04f28..175a050f9 100644 --- a/package.json +++ b/package.json @@ -9,22 +9,23 @@ "build:appimage": "NO_STRIP=1 tauri build --bundles appimage", "typecheck": "tsc --noEmit", "preview": "vite preview", - "tauri": "tauri" + "macos:build-signed": "bash scripts/macos-build-signed.sh", + "tauri": "PATH=$HOME/.cargo/bin:$PATH tauri" }, "dependencies": { "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "lucide-react": "^0.562.0", "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "@xterm/xterm": "^5.5.0", - "@xterm/addon-fit": "^0.10.0" + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tauri-apps/cli": "^2", 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-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-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 new file mode 100755 index 000000000..18c3ff56d --- /dev/null +++ b/scripts/ios-run-sim.sh @@ -0,0 +1,54 @@ +#!/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})..." +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}" + +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/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..2463541fb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,10 +26,16 @@ serde_json = "1" tokio = { version = "1", features = ["io-util", "process", "rt", "sync", "time"] } uuid = { version = "1", features = ["v4"] } tauri-plugin-dialog = "2" -git2 = "0.20.3" 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" +git2 = "0.20.3" +portable-pty = "0.8" diff --git a/src-tauri/entitlements.macos.plist b/src-tauri/entitlements.macos.plist new file mode 100644 index 000000000..756a06bcc --- /dev/null +++ b/src-tauri/entitlements.macos.plist @@ -0,0 +1,34 @@ + + + + + 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.icloud-container-environment + Development + 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..4f489c30e --- /dev/null +++ b/src-tauri/src/cloudkit.rs @@ -0,0 +1,1986 @@ +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +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, +} + +#[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) +} + +pub(crate) fn cloudkit_cli_test(container_id: String) -> Result { + cloudkit_impl::ensure_cloudkit_allowed()?; + cloudkit_impl::test_roundtrip_blocking(container_id) +} + +pub(crate) fn cloudkit_cli_latest_runner(container_id: String) -> 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) = { + 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())? +} + +#[tauri::command] +pub(crate) async fn cloudkit_publish_presence( + name: String, + platform: String, + state: State<'_, AppState>, +) -> 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::{HashMap, 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, CKQuery, CKRecord, CKRecordID, CKRecordValue, + }; + use objc2_foundation::{NSArray, NSError, NSNumber, NSPredicate, NSSortDescriptor, NSString}; + use tauri::AppHandle; + use tauri::Manager; + use uuid::Uuid; + + 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 + .as_deref() + .map(|error| error.to_string()) + .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 + // 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(()) + } + + 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); + catch(|| unsafe { CKContainer::containerWithIdentifier(&identifier) }) + .map_err(exception_to_string) + }) + } + + 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))); + 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())? + } + + 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, + })) + } + + fn command_result_exists_blocking( + database: &CKDatabase, + runner_id: &str, + command_id: &str, + ) -> Result { + let record_id = record_id_from_name(&result_record_name(runner_id, command_id)); + Ok(fetch_record_retained(database, &record_id)?.is_some()) + } + + 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 prefetch_thread_ids: Vec = threads + .iter() + .take(3) + .filter_map(|thread| { + thread + .get("id") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + }) + .collect(); + 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)?; + + // Opportunistically cache a few recent thread snapshots so the iOS client can render + // instantly when switching between threads. + for thread_id in prefetch_thread_ids { + 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) { + let _ = publish_thread_snapshot(container_id, runner_id, workspace_id, &thread_id, thread).await; + } + } + 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 agent_message_count(thread: &serde_json::Value) -> usize { + let turns = match thread.get("turns").and_then(|v| v.as_array()) { + Some(turns) => turns, + None => return 0, + }; + let mut count = 0usize; + 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() { + count += 1; + } + } + } + } + } + count + } + + 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 { + fn parse_ts_ms(value: &serde_json::Value) -> Option { + let raw = match value { + serde_json::Value::Number(n) => n.as_i64().or_else(|| n.as_u64().map(|u| u as i64)), + serde_json::Value::String(s) => s.trim().parse::().ok(), + _ => None, + }?; + if raw <= 0 { + return None; + } + // Heuristic: seconds vs milliseconds. + Some(if raw < 1_000_000_000_000 { raw * 1000 } else { raw }) + } + + fn turn_ts_ms(turn: &serde_json::Value) -> Option { + let obj = turn.as_object()?; + // Try common field names from Codex + our own adapters. + obj.get("createdAtMs") + .and_then(parse_ts_ms) + .or_else(|| obj.get("created_at_ms").and_then(parse_ts_ms)) + .or_else(|| obj.get("createdAt").and_then(parse_ts_ms)) + .or_else(|| obj.get("created_at").and_then(parse_ts_ms)) + .or_else(|| obj.get("startedAt").and_then(parse_ts_ms)) + .or_else(|| obj.get("started_at").and_then(parse_ts_ms)) + } + + let turns = thread + .get("turns") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let mut turns_with_meta: Vec<(usize, serde_json::Value, Option)> = turns + .into_iter() + .enumerate() + .map(|(idx, turn)| { + let ts = turn_ts_ms(&turn); + (idx, turn, ts) + }) + .collect(); + + // Normalize ordering: if we have timestamps for at least two turns, sort chronologically. + let ts_count = turns_with_meta.iter().filter(|(_, _, ts)| ts.is_some()).count(); + if ts_count >= 2 && turns_with_meta.len() >= 2 { + turns_with_meta.sort_by(|(a_idx, _, a_ts), (b_idx, _, b_ts)| { + let a_key = a_ts.unwrap_or(i64::MAX); + let b_key = b_ts.unwrap_or(i64::MAX); + a_key.cmp(&b_key).then(a_idx.cmp(b_idx)) + }); + } + + // Preserve turn boundaries when trimming so we don't show an assistant answer without the + // user prompt that triggered it. + let mut groups: Vec> = Vec::new(); + for (_, turn, _) in turns_with_meta { + let items = match turn.get("items").and_then(|v| v.as_array()) { + Some(items) => items, + None => continue, + }; + let mut group: Vec = Vec::new(); + 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 + }; + group.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 + }; + group.push(serde_json::json!({ + "id": id, + "kind": "message", + "role": "assistant", + "text": rendered, + })); + } + _ => {} + } + } + if !group.is_empty() { + groups.push(group); + } + } + + let mut total = groups.iter().map(|g| g.len()).sum::(); + if max_items > 0 && total > max_items && !groups.is_empty() { + while total > max_items && groups.len() > 1 { + if let Some(first) = groups.first() { + total = total.saturating_sub(first.len()); + } + groups.remove(0); + } + if total > max_items { + if let Some(last) = groups.last_mut() { + if last.len() > max_items { + last.drain(0..(last.len() - max_items)); + } + } + } + } + + let mut out: Vec = Vec::new(); + for mut group in groups { + out.append(&mut group); + } + 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")? + }; + + // Baseline the current thread state so we don't incorrectly treat an existing + // agent message as the "response" for this new user message. + let (baseline_agent_messages, baseline_last_agent_text) = { + let resume = session + .send_request("thread/resume", serde_json::json!({ "threadId": thread_id })) + .await + .ok(); + match resume.and_then(|value| extract_thread_from_resume_response(&value)) { + Some(thread) => { + if can_publish { + let _ = publish_thread_snapshot( + &container_id, + &runner_id, + workspace_id, + thread_id, + thread.clone(), + ) + .await; + } + ( + agent_message_count(&thread), + latest_agent_text(&thread).unwrap_or_default(), + ) + } + None => (0usize, String::new()), + } + }; + + 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; + } + let agent_messages = agent_message_count(&thread); + if agent_messages <= baseline_agent_messages { + continue; + } + let latest = latest_agent_text(&thread).unwrap_or_default(); + if latest.trim().is_empty() { + continue; + } + // Avoid returning the baseline message (can happen if the runner restarts + // and the first resume races with the new turn being persisted). + if latest.trim() == baseline_last_agent_text.trim() { + continue; + } + assistant_text = Some(latest); + 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); + let mut recent_send_dedupe: HashMap = HashMap::new(); + let mut last_dedupe_cleanup = Instant::now(); + let mut logged_disallowed = false; + + 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; + } + + // On macOS debug builds, CloudKit often crashes the process when the binary isn't + // properly signed with iCloud entitlements. Gate the poller to keep the app usable. + if let Err(message) = ensure_cloudkit_allowed() { + if !logged_disallowed { + debug_log(&message); + logged_disallowed = true; + } + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } else { + logged_disallowed = false; + } + + 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 we already wrote a result for this command, never execute again. + // This makes the runner idempotent across restarts and across multiple instances. + if let Ok(true) = command_result_exists_blocking(&database, &runner_id, &command_id) { + debug_log(&format!("skipping already-processed command {command_id}")); + let record_id = record_id_from_name(&command_record_name(&runner_id, &command_id)); + let _ = delete_record_blocking(&database, &record_id); + 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()); + + if command.command_type == "sendUserMessage" { + let client_id = command.client_id.clone().unwrap_or_default(); + let workspace_id = command.args.get("workspaceId").and_then(|v| v.as_str()).unwrap_or(""); + let thread_id = command.args.get("threadId").and_then(|v| v.as_str()).unwrap_or(""); + let text = command.args.get("text").and_then(|v| v.as_str()).unwrap_or(""); + if !client_id.is_empty() && !workspace_id.is_empty() && !thread_id.is_empty() && !text.is_empty() { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + client_id.hash(&mut hasher); + workspace_id.hash(&mut hasher); + thread_id.hash(&mut hasher); + text.hash(&mut hasher); + let key = hasher.finish(); + if let Some(prev) = recent_send_dedupe.get(&key) { + // CloudKit commands can be observed multiple times across devices/polls. + // Keep this window fairly large to avoid accidental double-execution from + // UI retries or delayed record visibility. + if prev.elapsed() < Duration::from_millis(10_000) { + debug_log(&format!("skipping duplicate sendUserMessage command {command_id}")); + let payload_json = serde_json::json!({ "skippedDuplicate": true }).to_string(); + let _ = write_command_result_blocking( + container_id.clone(), + runner_id.clone(), + command_id.clone(), + true, + payload_json, + ); + let record_id = record_id_from_name(&command_record_name(&runner_id, &command_id)); + let _ = delete_record_blocking(&database, &record_id); + continue; + } + } + recent_send_dedupe.insert(key, Instant::now()); + } + } + + 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(); + } + + if last_dedupe_cleanup.elapsed() > Duration::from_secs(30) && recent_send_dedupe.len() > 200 { + recent_send_dedupe.retain(|_, instant| instant.elapsed() < Duration::from_secs(30)); + last_dedupe_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::>(); + 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/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/git_stub.rs b/src-tauri/src/git_stub.rs new file mode 100644 index 000000000..1d3561309 --- /dev/null +++ b/src-tauri/src/git_stub.rs @@ -0,0 +1,73 @@ +use tauri::State; + +use crate::state::AppState; +use crate::types::{GitFileDiff, GitHubIssuesResponse, 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 get_github_issues( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + 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 af108ad8b..036a83ebd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,19 +1,98 @@ +#[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; +#[cfg(not(target_os = "ios"))] mod terminal; +#[cfg(target_os = "ios")] +mod terminal_stub; +#[cfg(target_os = "ios")] +use terminal_stub as terminal; mod storage; 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()) +} + +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()) +} + +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")] @@ -24,109 +103,117 @@ 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 file_menu = Submenu::with_items( - handle, - "File", - true, - &[ - &PredefinedMenuItem::close_window(handle, None)?, - #[cfg(not(target_os = "macos"))] - &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 view_menu = Submenu::with_items( - handle, - "View", - true, - &[&PredefinedMenuItem::fullscreen(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 help_menu = Submenu::with_items(handle, "Help", true, &[])?; - - 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 mut builder = tauri::Builder::default(); + + #[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 file_menu = Submenu::with_items( + handle, + "File", + true, + &[ + &PredefinedMenuItem::close_window(handle, None)?, + #[cfg(not(target_os = "macos"))] + &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 view_menu = Submenu::with_items( + handle, + "View", + true, + &[&PredefinedMenuItem::fullscreen(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 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); + cloudkit::start_cloudkit_command_poller(app.handle().clone()); #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; @@ -136,8 +223,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 0b817d97c..357c3fb3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,5 +5,162 @@ 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); + } + } + } + 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/terminal_stub.rs b/src-tauri/src/terminal_stub.rs new file mode 100644 index 000000000..0f3f7640d --- /dev/null +++ b/src-tauri/src/terminal_stub.rs @@ -0,0 +1,62 @@ +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::state::AppState; + +pub(crate) struct TerminalSession { + #[allow(dead_code)] + pub(crate) id: String, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct TerminalSessionInfo { + id: String, +} + +fn unsupported() -> Result<(), String> { + Err("Terminal is not supported on iOS.".to_string()) +} + +#[tauri::command] +pub(crate) async fn terminal_open( + _workspace_id: String, + _terminal_id: String, + _cols: u16, + _rows: u16, + _state: State<'_, AppState>, + _app: AppHandle, +) -> Result { + unsupported()?; + unreachable!() +} + +#[tauri::command] +pub(crate) async fn terminal_write( + _workspace_id: String, + _terminal_id: String, + _data: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + unsupported() +} + +#[tauri::command] +pub(crate) async fn terminal_resize( + _workspace_id: String, + _terminal_id: String, + _cols: u16, + _rows: u16, + _state: State<'_, AppState>, +) -> Result<(), String> { + unsupported() +} + +#[tauri::command] +pub(crate) async fn terminal_close( + _workspace_id: String, + _terminal_id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + unsupported() +} + diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index f517317e5..a415e15ea 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -128,6 +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")] @@ -155,6 +179,18 @@ impl Default for AppSettings { fn default() -> Self { Self { codex_bin: None, + 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, @@ -170,6 +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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 034071eec..154f7d3fd 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,14 +39,17 @@ "bundle": { "active": true, "targets": "all", - "createUpdaterArtifacts": true, + "createUpdaterArtifacts": false, "icon": [ "icons/32x32.png", "icons/128x128.png", "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" + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 6d42ddc55..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"; @@ -61,7 +62,10 @@ 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 { isAppleMobile } from "./utils/platform"; +import { CloudClientApp } from "./features/app/components/CloudClientApp"; import type { AccessMode, DiffLineReference, QueuedMessage, WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -144,7 +148,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); @@ -959,6 +963,8 @@ function MainApp() { await queueSaveSettings(next); }} onRunDoctor={doctor} + onCloudKitStatus={cloudkitStatus} + onCloudKitTest={cloudkitTest} onUpdateWorkspaceCodexBin={async (id, codexBin) => { await updateWorkspaceCodexBin(id, codexBin); }} @@ -976,6 +982,9 @@ function App() { if (windowLabel === "about") { return ; } + if (isAppleMobile()) { + return ; + } return ; } diff --git a/src/cloud/cloudCache.ts b/src/cloud/cloudCache.ts new file mode 100644 index 000000000..7170ee4d5 --- /dev/null +++ b/src/cloud/cloudCache.ts @@ -0,0 +1,194 @@ +import type { CloudKitRunnerInfo } from "../types"; +import type { + CloudGlobalSnapshot, + CloudThreadSnapshot, + CloudWorkspaceSnapshot, +} from "./cloudTypes"; + +const STORAGE_KEY = "codexmonitor.cloud.cache.v1"; +const MAX_THREAD_ENTRIES = 8; +const MAX_THREAD_ITEMS = 80; +const MAX_TEXT_CHARS = 2000; +const MAX_CACHE_AGE_MS = 1000 * 60 * 60 * 24 * 14; // 14 days + +type CloudCacheV1 = { + v: 1; + updatedAtMs: number; + runner: CloudKitRunnerInfo | null; + global: CloudGlobalSnapshot | null; + workspaces: Record; + threads: Record; + threadOrder: string[]; +}; + +function nowMs() { + return Date.now(); +} + +function safeParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function isObject(value: unknown): value is Record { + return Boolean(value && typeof value === "object"); +} + +function truncate(value: string, maxChars: number) { + if (!value) return ""; + if (value.length <= maxChars) return value; + return value.slice(0, maxChars) + "…"; +} + +function threadKey(workspaceId: string, threadId: string) { + return `${workspaceId}::${threadId}`; +} + +function compactThreadSnapshot(snapshot: CloudThreadSnapshot): CloudThreadSnapshot { + const payload = snapshot.payload ?? ({} as CloudThreadSnapshot["payload"]); + const items = Array.isArray(payload.items) ? payload.items : null; + if (!items || items.length === 0) { + return snapshot; + } + const trimmedItems = items + .slice(-MAX_THREAD_ITEMS) + .map((item) => { + if (item.kind !== "message") { + return item; + } + return { + ...item, + text: truncate(item.text, MAX_TEXT_CHARS), + }; + }); + return { + ...snapshot, + payload: { + ...payload, + items: trimmedItems, + thread: null, + }, + }; +} + +function coerceCache(value: unknown): CloudCacheV1 | null { + if (!isObject(value)) return null; + if ((value as any).v !== 1) return null; + + const updatedAtMs = typeof value.updatedAtMs === "number" ? value.updatedAtMs : 0; + if (updatedAtMs && nowMs() - updatedAtMs > MAX_CACHE_AGE_MS) { + return null; + } + + return { + v: 1, + updatedAtMs: updatedAtMs || nowMs(), + runner: (value.runner as CloudKitRunnerInfo | null) ?? null, + global: (value.global as CloudGlobalSnapshot | null) ?? null, + workspaces: isObject(value.workspaces) ? (value.workspaces as any) : {}, + threads: isObject(value.threads) ? (value.threads as any) : {}, + threadOrder: Array.isArray(value.threadOrder) + ? (value.threadOrder as string[]).filter((key) => typeof key === "string") + : [], + }; +} + +export function loadCloudCache(): CloudCacheV1 | null { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return coerceCache(safeParse(raw)); + } catch { + return null; + } +} + +function saveCloudCache(cache: CloudCacheV1) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cache)); + } catch { + // ignore (quota, private mode, etc.) + } +} + +export function writeCloudCacheRunner(next: CloudKitRunnerInfo | null) { + const cache = loadCloudCache() ?? { + v: 1, + updatedAtMs: nowMs(), + runner: null, + global: null, + workspaces: {}, + threads: {}, + threadOrder: [], + }; + cache.runner = next; + cache.updatedAtMs = nowMs(); + saveCloudCache(cache); +} + +export function writeCloudCacheGlobal(next: CloudGlobalSnapshot | null) { + const cache = loadCloudCache() ?? { + v: 1, + updatedAtMs: nowMs(), + runner: null, + global: null, + workspaces: {}, + threads: {}, + threadOrder: [], + }; + cache.global = next; + cache.updatedAtMs = nowMs(); + saveCloudCache(cache); +} + +export function writeCloudCacheWorkspace(next: CloudWorkspaceSnapshot) { + const cache = loadCloudCache() ?? { + v: 1, + updatedAtMs: nowMs(), + runner: null, + global: null, + workspaces: {}, + threads: {}, + threadOrder: [], + }; + cache.workspaces[next.payload.workspaceId] = next; + cache.updatedAtMs = nowMs(); + saveCloudCache(cache); +} + +export function writeCloudCacheThread(next: CloudThreadSnapshot) { + const cache = loadCloudCache() ?? { + v: 1, + updatedAtMs: nowMs(), + runner: null, + global: null, + workspaces: {}, + threads: {}, + threadOrder: [], + }; + const key = threadKey(next.payload.workspaceId, next.payload.threadId); + cache.threads[key] = compactThreadSnapshot(next); + cache.threadOrder = [key, ...cache.threadOrder.filter((entry) => entry !== key)]; + if (cache.threadOrder.length > MAX_THREAD_ENTRIES) { + const removed = cache.threadOrder.slice(MAX_THREAD_ENTRIES); + removed.forEach((entry) => { + delete cache.threads[entry]; + }); + cache.threadOrder = cache.threadOrder.slice(0, MAX_THREAD_ENTRIES); + } + cache.updatedAtMs = nowMs(); + saveCloudCache(cache); +} + +export function getCachedThreadSnapshot( + cache: CloudCacheV1 | null, + workspaceId: string, + threadId: string, +) { + if (!cache) return null; + return cache.threads[threadKey(workspaceId, threadId)] ?? null; +} + diff --git a/src/cloud/cloudTelemetry.ts b/src/cloud/cloudTelemetry.ts new file mode 100644 index 000000000..f7592f037 --- /dev/null +++ b/src/cloud/cloudTelemetry.ts @@ -0,0 +1,59 @@ +export type CloudTelemetryEntry = { + ts: number; + event: string; + workspaceId?: string; + threadId?: string; + scopeKey?: string; + commandId?: string; + fromCache?: boolean; + durationMs?: number; + note?: string; +}; + +const STORAGE_KEY = "codexmonitor.cloud.telemetry.v1"; +const MAX_ENTRIES = 250; + +function safeParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function readCloudTelemetry(): CloudTelemetryEntry[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = safeParse(raw); + if (!Array.isArray(parsed)) return []; + return (parsed as CloudTelemetryEntry[]).filter( + (entry) => entry && typeof entry.ts === "number" && typeof entry.event === "string", + ); + } catch { + return []; + } +} + +export function clearCloudTelemetry() { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // ignore + } +} + +export function pushCloudTelemetry(entry: Omit & { ts?: number }) { + const next: CloudTelemetryEntry = { + ...entry, + ts: entry.ts ?? Date.now(), + }; + try { + const prev = readCloudTelemetry(); + const merged = [...prev, next].slice(-MAX_ENTRIES); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(merged)); + } catch { + // ignore (quota, private mode, etc.) + } +} + 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..a6c589890 --- /dev/null +++ b/src/features/app/components/CloudClientApp.tsx @@ -0,0 +1,1592 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ConversationItem, ThreadSummary, 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 { + getCachedThreadSnapshot, + loadCloudCache, + writeCloudCacheGlobal, + writeCloudCacheRunner, + writeCloudCacheThread, + writeCloudCacheWorkspace, +} from "../../../cloud/cloudCache"; +import { pushCloudTelemetry } from "../../../cloud/cloudTelemetry"; +import { Home } from "../../home/components/Home"; +import { useLayoutMode } from "../../layout/hooks/useLayoutMode"; +import { useResizablePanels } from "../../layout/hooks/useResizablePanels"; +import { Composer } from "../../composer/components/Composer"; +import { Messages } from "../../messages/components/Messages"; +import { SettingsView } from "../../settings/components/SettingsView"; +import { useAppSettings } from "../../settings/hooks/useAppSettings"; +import { buildItemsFromThread } from "../../../utils/threadItems"; +import { MainHeader } from "./MainHeader"; +import { Sidebar } from "./Sidebar"; +import { TabBar } from "./TabBar"; +import { TabletNav } from "./TabletNav"; + +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; + phase: "submitting" | "waitingResult" | "waitingReply" | "error"; + resultPayloadJson?: string | null; + error?: string; +}; + +type AwaitingReply = { + commandId: string; + workspaceId: string; + threadId: string; + startedAtMs: number; + baselineAssistantCount: number; +}; + +export function CloudClientApp() { + const { settings: appSettings, saveSettings, doctor } = useAppSettings(); + // iOS/iPadOS build: always operate in Cloud mode. + const cloudEnabled = true; + const { + sidebarWidth, + onSidebarResizeStart, + } = useResizablePanels(); + const layoutMode = useLayoutMode(); + const isCompact = layoutMode !== "desktop"; + const isTablet = layoutMode === "tablet"; + const isPhone = layoutMode === "phone"; + const clientId = useMemo(() => ensureClientId(), []); + const [activeTab, setActiveTab] = useState<"projects" | "codex" | "git" | "log">( + "projects", + ); + const tabletTab = activeTab === "projects" ? "codex" : activeTab; + const [runnerId, setRunnerId] = useState(null); + const [runnerLabel, setRunnerLabel] = useState(null); + const [runnerOnline, setRunnerOnline] = useState(false); + const [global, setGlobal] = useState(null); + const [workspaceSnaps, setWorkspaceSnaps] = useState>( + {}, + ); + const [threadSnap, setThreadSnap] = useState(null); + const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); + const [activeThreadId, setActiveThreadId] = useState(null); + const [settingsOpen, setSettingsOpen] = useState(false); + const [accessMode, setAccessMode] = useState<"read-only" | "current" | "full-access">( + "current", + ); + const [reduceTransparency, setReduceTransparency] = useState(() => { + try { + const stored = window.localStorage.getItem("reduceTransparency"); + // iOS: default to reduced transparency (no vibrancy background). + return stored == null ? true : stored === "true"; + } catch { + return true; + } + }); + const [pendingByThreadKey, setPendingByThreadKey] = useState>( + {}, + ); + const pendingByThreadKeyRef = useRef(pendingByThreadKey); + useEffect(() => { + pendingByThreadKeyRef.current = pendingByThreadKey; + }, [pendingByThreadKey]); + + const [awaitingByThreadKey, setAwaitingByThreadKey] = useState>( + {}, + ); + const awaitingByThreadKeyRef = useRef(awaitingByThreadKey); + useEffect(() => { + awaitingByThreadKeyRef.current = awaitingByThreadKey; + }, [awaitingByThreadKey]); + + const [localItemsByThreadKey, setLocalItemsByThreadKey] = useState< + Record + >({}); + const lastThreadUpdatedAtByKey = useRef>({}); + const [cloudError, setCloudError] = useState(null); + const [threadLoadMode, setThreadLoadMode] = useState<"idle" | "loading" | "syncing">("idle"); + const [threadLoadLabel, setThreadLoadLabel] = useState(null); + const e2eThreadRequested = useRef(false); + const e2eBaseline = useRef<{ assistantCount: number } | null>(null); + const e2eCompleted = useRef(false); + const lastWorkspaceUpdatedAt = useRef>({}); + const lastWorkspaceFetchAt = useRef>({}); + const lastThreadFetchAt = useRef(0); + const lastBackgroundThreadFetchAtByKey = useRef>({}); + const lastSendRef = useRef<{ + workspaceId: string; + threadId: string; + text: string; + atMs: number; + } | null>(null); + + const threadKey = useCallback((workspaceId: string, threadId: string) => { + return `${workspaceId}::${threadId}`; + }, []); + + const activeThreadKey = useMemo(() => { + if (!activeWorkspaceId || !activeThreadId) return null; + return threadKey(activeWorkspaceId, activeThreadId); + }, [activeThreadId, activeWorkspaceId, threadKey]); + + const activePending = useMemo(() => { + if (!activeThreadKey) return null; + return pendingByThreadKey[activeThreadKey] ?? null; + }, [activeThreadKey, pendingByThreadKey]); + + const activeAwaiting = useMemo(() => { + if (!activeThreadKey) return null; + return awaitingByThreadKey[activeThreadKey] ?? null; + }, [activeThreadKey, awaitingByThreadKey]); + + const countAssistantMessages = useCallback((items: ConversationItem[]) => { + return items.filter((item) => item.kind === "message" && item.role === "assistant").length; + }, []); + + const reconcileLocalItems = useCallback( + (key: string, snapshotItems: ConversationItem[]) => { + setLocalItemsByThreadKey((prev) => { + const local = prev[key]; + if (!local || local.length === 0) return prev; + + // Drop local items that are already present in the snapshot (exact role+text match). + const snapshotSigs = new Set( + snapshotItems + .filter((item) => item.kind === "message") + .map((item) => `${item.kind}:${(item as any).role}:${(item as any).text}`), + ); + const filtered = local.filter((item) => { + if (item.kind !== "message") return true; + const sig = `${item.kind}:${(item as any).role}:${(item as any).text}`; + return !snapshotSigs.has(sig); + }); + if (filtered.length === local.length) return prev; + const next = { ...prev }; + if (filtered.length) { + next[key] = filtered; + } else { + delete next[key]; + } + return next; + }); + }, + [], + ); + + const applyAwaitingResolutionFromItems = useCallback( + (key: string, workspaceId: string, threadId: string, items: ConversationItem[]) => { + const awaiting = awaitingByThreadKeyRef.current[key]; + if (!awaiting) return; + const assistantCount = countAssistantMessages(items); + if (assistantCount > awaiting.baselineAssistantCount) { + pushCloudTelemetry({ + event: "reply.seen", + fromCache: false, + workspaceId, + threadId, + commandId: awaiting.commandId, + note: `assistant+${assistantCount - awaiting.baselineAssistantCount}`, + }); + setAwaitingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + setPendingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + } + }, + [countAssistantMessages], + ); + + const e2eEnabled = (import.meta as any).env?.VITE_E2E === "1"; + const pollIntervalMs = useMemo(() => { + const configured = appSettings.cloudKitPollIntervalMs; + if (typeof configured === "number" && Number.isFinite(configured)) { + return Math.min(Math.max(configured, 1000), 30_000); + } + return 5000; + }, [appSettings.cloudKitPollIntervalMs]); + + const shouldFastPollActiveThread = + Boolean(activeAwaiting) || Boolean(activePending && activePending.phase !== "error"); + + const restoreRequested = useRef(false); + useEffect(() => { + if (e2eEnabled) { + return; + } + if (restoreRequested.current) { + return; + } + restoreRequested.current = true; + try { + const storedWorkspaceId = window.localStorage.getItem("cloud.activeWorkspaceId"); + const storedThreadId = window.localStorage.getItem("cloud.activeThreadId"); + if (storedWorkspaceId) { + setActiveWorkspaceId(storedWorkspaceId); + } + if (storedThreadId) { + setActiveThreadId(storedThreadId); + } + } catch { + // ignore + } + }, [e2eEnabled]); + + useEffect(() => { + if (e2eEnabled) { + return; + } + try { + if (activeWorkspaceId) { + window.localStorage.setItem("cloud.activeWorkspaceId", activeWorkspaceId); + } else { + window.localStorage.removeItem("cloud.activeWorkspaceId"); + } + if (activeThreadId) { + window.localStorage.setItem("cloud.activeThreadId", activeThreadId); + } else { + window.localStorage.removeItem("cloud.activeThreadId"); + } + } catch { + // ignore + } + }, [activeThreadId, activeWorkspaceId, e2eEnabled]); + + const cacheHydrated = useRef(false); + useEffect(() => { + if (cacheHydrated.current) { + return; + } + cacheHydrated.current = true; + + const cached = loadCloudCache(); + if (!cached) { + pushCloudTelemetry({ event: "cache.hydrate", fromCache: false, note: "empty" }); + return; + } + + pushCloudTelemetry({ + event: "cache.hydrate", + fromCache: true, + note: `ws=${Object.keys(cached.workspaces).length} th=${Object.keys(cached.threads).length}`, + }); + + if (cached.runner) { + setRunnerId(cached.runner.runnerId); + setRunnerLabel(`${cached.runner.name} (${cached.runner.platform})`); + setRunnerOnline(isRunnerOnline(cached.runner.updatedAtMs)); + } + + if (cached.global) { + setGlobal(cached.global); + } + + if (Object.keys(cached.workspaces).length > 0) { + setWorkspaceSnaps(cached.workspaces); + const nextWorkspaceTs: Record = {}; + Object.values(cached.workspaces).forEach((snap) => { + nextWorkspaceTs[snap.payload.workspaceId] = snap.ts; + }); + lastWorkspaceUpdatedAt.current = nextWorkspaceTs; + } + + if (activeWorkspaceId && activeThreadId) { + const cachedThread = getCachedThreadSnapshot(cached, activeWorkspaceId, activeThreadId); + if (cachedThread) { + const key = threadKey(activeWorkspaceId, activeThreadId); + lastThreadUpdatedAtByKey.current[key] = cachedThread.ts; + setThreadSnap(cachedThread); + pushCloudTelemetry({ + event: "thread.apply", + fromCache: true, + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + note: `ts=${cachedThread.ts}`, + }); + } + } + }, [activeThreadId, activeWorkspaceId]); + + useEffect(() => { + if (!activeWorkspaceId || !activeThreadId) { + return; + } + if (threadSnap?.payload.threadId === activeThreadId && threadSnap.payload.workspaceId === activeWorkspaceId) { + return; + } + const cached = loadCloudCache(); + const cachedThread = getCachedThreadSnapshot(cached, activeWorkspaceId, activeThreadId); + if (!cachedThread) { + return; + } + const key = threadKey(activeWorkspaceId, activeThreadId); + const prevTs = lastThreadUpdatedAtByKey.current[key] ?? 0; + if (cachedThread.ts <= prevTs) { + return; + } + lastThreadUpdatedAtByKey.current[key] = cachedThread.ts; + setThreadSnap(cachedThread); + }, [activeThreadId, activeWorkspaceId, threadSnap]); + + useEffect(() => { + try { + window.localStorage.setItem("reduceTransparency", String(reduceTransparency)); + } catch { + // ignore + } + }, [reduceTransparency]); + + const submitCommand = useCallback( + async (type: string, args: Record) => { + if (!cloudEnabled || !runnerId) { + return null; + } + const commandId = crypto.randomUUID(); + pushCloudTelemetry({ + event: "command.submit", + commandId, + workspaceId: + typeof (args as any).workspaceId === "string" ? ((args as any).workspaceId as string) : undefined, + threadId: + typeof (args as any).threadId === "string" ? ((args as any).threadId as string) : undefined, + note: type, + }); + 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); + setWorkspaceSnaps({}); + 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); + // Keep the last cached snapshots visible (offline-first); just mark as offline. + return; + } + setRunnerId(runner.runnerId); + setRunnerLabel(`${runner.name} (${runner.platform})`); + setRunnerOnline(isRunnerOnline(runner.updatedAtMs)); + writeCloudCacheRunner(runner); + + let globalSnapshot: CloudGlobalSnapshot | null = null; + const globalScope = globalScopeKey(); + const globalFetchStart = performance.now(); + const globalRecord = await cloudkitGetSnapshot(runner.runnerId, globalScope); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey: globalScope, + fromCache: false, + durationMs: performance.now() - globalFetchStart, + }); + if (globalRecord?.payloadJson) { + const parsed = parseCloudSnapshot(globalRecord.payloadJson); + if (parsed) { + globalSnapshot = parsed as CloudGlobalSnapshot; + setGlobal(globalSnapshot); + writeCloudCacheGlobal(globalSnapshot); + } + } + + const workspaceIds = (globalSnapshot?.payload.workspaces ?? []).map((ws) => ws.id); + if (workspaceIds.length > 0) { + const now = Date.now(); + const refreshMs = Math.max(pollIntervalMs * 2, 6000); + const idsToFetch: string[] = []; + + if (activeWorkspaceId && workspaceIds.includes(activeWorkspaceId)) { + idsToFetch.push(activeWorkspaceId); + } else if (workspaceIds.length > 0) { + // Keep at least one workspace hydrated so the Projects list isn't empty. + idsToFetch.push(workspaceIds[0]); + } + + for (const workspaceId of workspaceIds) { + if (idsToFetch.length >= 2) break; + if (idsToFetch.includes(workspaceId)) continue; + if ((lastWorkspaceUpdatedAt.current[workspaceId] ?? 0) === 0) { + idsToFetch.push(workspaceId); + } + } + + const snapshots = await Promise.all( + idsToFetch.map(async (workspaceId) => { + try { + const lastFetch = lastWorkspaceFetchAt.current[workspaceId] ?? 0; + if (lastFetch && now - lastFetch < refreshMs) { + return null; + } + lastWorkspaceFetchAt.current[workspaceId] = now; + const scopeKey = workspaceScopeKey(workspaceId); + const wsFetchStart = performance.now(); + const wsRecord = await cloudkitGetSnapshot(runner.runnerId, scopeKey); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey, + workspaceId, + fromCache: false, + durationMs: performance.now() - wsFetchStart, + }); + if (!wsRecord?.payloadJson) return null; + const parsed = parseCloudSnapshot( + wsRecord.payloadJson, + ); + if (!parsed) return null; + const next = parsed as CloudWorkspaceSnapshot; + const prevTs = lastWorkspaceUpdatedAt.current[workspaceId] ?? 0; + if (next.ts <= prevTs) { + return null; + } + lastWorkspaceUpdatedAt.current[workspaceId] = next.ts; + return next; + } catch { + return null; + } + }), + ); + const nextById: Record = {}; + for (const snap of snapshots) { + if (!snap) continue; + nextById[snap.payload.workspaceId] = snap; + writeCloudCacheWorkspace(snap); + } + if (Object.keys(nextById).length > 0) { + setWorkspaceSnaps((prev) => ({ ...prev, ...nextById })); + } + } + + if (activeWorkspaceId && activeThreadId) { + const now = Date.now(); + const refreshMs = + threadLoadMode !== "idle" || shouldFastPollActiveThread + ? pollIntervalMs + : Math.max(pollIntervalMs * 2, 8000); + if (now - lastThreadFetchAt.current >= refreshMs) { + lastThreadFetchAt.current = now; + const scopeKey = threadScopeKey(activeWorkspaceId, activeThreadId); + const thFetchStart = performance.now(); + const thRecord = await cloudkitGetSnapshot(runner.runnerId, scopeKey); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey, + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + fromCache: false, + durationMs: performance.now() - thFetchStart, + }); + if (thRecord?.payloadJson) { + const parsed = parseCloudSnapshot(thRecord.payloadJson); + if (parsed) { + const next = parsed as CloudThreadSnapshot; + const key = threadKey(activeWorkspaceId, activeThreadId); + const prevTs = lastThreadUpdatedAtByKey.current[key] ?? 0; + if (next.ts > prevTs) { + lastThreadUpdatedAtByKey.current[key] = next.ts; + setThreadSnap(next); + writeCloudCacheThread(next); + const nextItems = Array.isArray(next.payload.items) ? (next.payload.items as ConversationItem[]) : []; + if (nextItems.length) { + reconcileLocalItems(key, nextItems); + applyAwaitingResolutionFromItems(key, activeWorkspaceId, activeThreadId, nextItems); + } + setThreadLoadMode("idle"); + setThreadLoadLabel(null); + } + } + } + } + // Background: keep polling any other threads that are awaiting a reply so we can + // clear spinners even if the user navigates away. + const awaitingKeys = Object.keys(awaitingByThreadKeyRef.current); + if (awaitingKeys.length > 0) { + for (const key of awaitingKeys) { + if (key === threadKey(activeWorkspaceId, activeThreadId)) continue; + const last = lastBackgroundThreadFetchAtByKey.current[key] ?? 0; + if (now - last < pollIntervalMs) continue; + lastBackgroundThreadFetchAtByKey.current[key] = now; + const [wsId, thId] = key.split("::"); + if (!wsId || !thId) continue; + const bgScopeKey = threadScopeKey(wsId, thId); + const bgFetchStart = performance.now(); + try { + const record = await cloudkitGetSnapshot(runner.runnerId, bgScopeKey); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey: bgScopeKey, + workspaceId: wsId, + threadId: thId, + fromCache: false, + durationMs: performance.now() - bgFetchStart, + note: "thread(background)", + }); + if (!record?.payloadJson) break; + const parsed = parseCloudSnapshot(record.payloadJson); + if (!parsed) break; + const next = parsed as CloudThreadSnapshot; + const prevTs = lastThreadUpdatedAtByKey.current[key] ?? 0; + if (next.ts > prevTs) { + lastThreadUpdatedAtByKey.current[key] = next.ts; + writeCloudCacheThread(next); + const nextItems = Array.isArray(next.payload.items) ? (next.payload.items as ConversationItem[]) : []; + if (nextItems.length) { + reconcileLocalItems(key, nextItems); + applyAwaitingResolutionFromItems(key, wsId, thId, nextItems); + } + } + } catch { + pushCloudTelemetry({ + event: "snapshot.fetch.error", + scopeKey: bgScopeKey, + workspaceId: wsId, + threadId: thId, + fromCache: false, + durationMs: performance.now() - bgFetchStart, + note: "thread(background)", + }); + } + break; + } + } + } else { + setThreadSnap(null); + } + } catch { + // ignore; we'll retry on next tick + } + }; + + void tick(); + const interval = window.setInterval(() => void tick(), pollIntervalMs); + return () => { + stopped = true; + window.clearInterval(interval); + }; + }, [activeThreadId, activeWorkspaceId, cloudEnabled, pollIntervalMs, shouldFastPollActiveThread, threadKey, threadLoadMode, applyAwaitingResolutionFromItems, reconcileLocalItems]); + + 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], + ); + + useEffect(() => { + if (e2eEnabled) { + return; + } + if (!activeWorkspaceId) { + return; + } + if (!workspaces.length) { + return; + } + const exists = workspaces.some((ws) => ws.id === activeWorkspaceId); + if (!exists) { + setActiveWorkspaceId(null); + setActiveThreadId(null); + } + }, [activeWorkspaceId, e2eEnabled, workspaces]); + + const threads = activeWorkspaceId ? (workspaceSnaps[activeWorkspaceId]?.payload.threads ?? []) : []; + + useEffect(() => { + if (e2eEnabled) { + return; + } + if (!activeWorkspaceId) { + return; + } + if (!activeThreadId) { + return; + } + if (threads.length === 0) { + return; + } + const exists = threads.some((t) => t.id === activeThreadId); + if (!exists) { + setActiveThreadId(null); + } + }, [activeThreadId, activeWorkspaceId, e2eEnabled, threads]); + + const activeItems: ConversationItem[] = useMemo(() => { + if (!threadSnap || threadSnap.payload.threadId !== activeThreadId) { + return []; + } + const items = Array.isArray(threadSnap.payload.items) ? threadSnap.payload.items : null; + const baseItems = + items && items.length + ? (items as ConversationItem[]) + : (() => { + const thread = threadSnap.payload.thread as Record | null | undefined; + if (thread && typeof thread === "object") { + return buildItemsFromThread(thread); + } + return []; + })(); + + if (!activeThreadKey) { + return baseItems; + } + const localItems = localItemsByThreadKey[activeThreadKey] ?? []; + if (!localItems.length) { + return baseItems; + } + + // Append local items (optimistic user/assistant messages) if they aren't already present. + const baseSigs = new Set( + baseItems + .filter((item) => item.kind === "message") + .map((item) => `${item.kind}:${(item as any).role}:${(item as any).text}`), + ); + const merged = baseItems.slice(); + for (const item of localItems) { + if (item.kind === "message") { + const sig = `${item.kind}:${(item as any).role}:${(item as any).text}`; + if (baseSigs.has(sig)) continue; + baseSigs.add(sig); + } + merged.push(item); + } + return merged; + }, [activeThreadId, activeThreadKey, localItemsByThreadKey, threadSnap]); + + useEffect(() => { + if (!activeThreadId) { + if (threadLoadMode !== "idle") { + setThreadLoadMode("idle"); + setThreadLoadLabel(null); + } + return; + } + if (threadLoadMode === "loading" && activeItems.length > 0) { + setThreadLoadMode("idle"); + setThreadLoadLabel(null); + } + }, [activeItems.length, activeThreadId, threadLoadMode]); + + useEffect(() => { + if (threadLoadMode !== "syncing") { + return; + } + if (!activeThreadId) { + return; + } + if (activeItems.length === 0) { + return; + } + const timeout = window.setTimeout(() => { + // Avoid flicker: keep the sync badge visible longer, because CloudKit snapshots can lag. + // The poller or a direct fetch will clear it earlier once a newer snapshot arrives. + setThreadLoadMode((mode) => (mode === "syncing" ? "idle" : mode)); + setThreadLoadLabel(null); + }, 12_000); + return () => window.clearTimeout(timeout); + }, [activeItems.length, activeThreadId, threadLoadMode]); + + const canSend = Boolean( + cloudEnabled && runnerId && runnerOnline && activeWorkspaceId && activeThreadId, + ); + + const handleSelectWorkspace = useCallback( + (id: string) => { + setActiveWorkspaceId(id); + setActiveThreadId(null); + setThreadLoadMode("idle"); + setThreadLoadLabel(null); + lastThreadFetchAt.current = 0; + if (isCompact) { + setActiveTab("codex"); + } + if (runnerId && cloudEnabled) { + void submitCommand("connectWorkspace", { workspaceId: id }); + void (async () => { + const scopeKey = workspaceScopeKey(id); + const fetchStart = performance.now(); + try { + const wsRecord = await cloudkitGetSnapshot(runnerId, scopeKey); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey, + workspaceId: id, + fromCache: false, + durationMs: performance.now() - fetchStart, + note: "workspace(select)", + }); + if (!wsRecord?.payloadJson) return; + const parsed = parseCloudSnapshot(wsRecord.payloadJson); + if (!parsed) return; + const next = parsed as CloudWorkspaceSnapshot; + const prevTs = lastWorkspaceUpdatedAt.current[id] ?? 0; + if (next.ts <= prevTs) return; + lastWorkspaceUpdatedAt.current[id] = next.ts; + setWorkspaceSnaps((prev) => ({ ...prev, [id]: next })); + writeCloudCacheWorkspace(next); + } catch { + pushCloudTelemetry({ + event: "snapshot.fetch.error", + scopeKey, + workspaceId: id, + fromCache: false, + durationMs: performance.now() - fetchStart, + note: "workspace(select)", + }); + // ignore; poller will retry. + } + })(); + } + }, + [cloudEnabled, isCompact, runnerId, submitCommand], + ); + + const handleSelectThread = useCallback( + (workspaceId: string, threadId: string) => { + if (activeWorkspaceId !== workspaceId) { + setActiveWorkspaceId(workspaceId); + } + setActiveThreadId(threadId); + lastThreadFetchAt.current = 0; + if (workspaceId) { + const cached = loadCloudCache(); + const cachedThread = getCachedThreadSnapshot(cached, workspaceId, threadId); + const hasCachedItems = Boolean( + cachedThread && + Array.isArray(cachedThread.payload.items) && + cachedThread.payload.items.length > 0, + ); + pushCloudTelemetry({ + event: "thread.cache", + fromCache: hasCachedItems, + workspaceId, + threadId, + }); + if (cachedThread) { + const key = threadKey(workspaceId, threadId); + const prevTs = lastThreadUpdatedAtByKey.current[key] ?? 0; + if (cachedThread.ts > prevTs) { + lastThreadUpdatedAtByKey.current[key] = cachedThread.ts; + } + setThreadSnap(cachedThread); + pushCloudTelemetry({ + event: "thread.apply", + fromCache: true, + workspaceId, + threadId, + note: `ts=${cachedThread.ts}`, + }); + } + setThreadLoadMode(hasCachedItems ? "syncing" : "loading"); + setThreadLoadLabel(hasCachedItems ? "Syncing from iCloud…" : "Loading conversation…"); + } else { + setThreadLoadMode("loading"); + setThreadLoadLabel("Loading conversation…"); + } + if (isCompact) { + setActiveTab("codex"); + } + if (runnerId && cloudEnabled && workspaceId) { + void submitCommand("resumeThread", { workspaceId, threadId }); + void (async () => { + const scopeKey = threadScopeKey(workspaceId, threadId); + const fetchStart = performance.now(); + try { + const record = await cloudkitGetSnapshot(runnerId, scopeKey); + pushCloudTelemetry({ + event: "snapshot.fetch", + scopeKey, + workspaceId, + threadId, + fromCache: false, + durationMs: performance.now() - fetchStart, + note: "thread(select)", + }); + if (!record?.payloadJson) return; + const parsed = parseCloudSnapshot(record.payloadJson); + if (!parsed) return; + const next = parsed as CloudThreadSnapshot; + const key = threadKey(workspaceId, threadId); + const prevTs = lastThreadUpdatedAtByKey.current[key] ?? 0; + if (next.ts > prevTs) { + lastThreadUpdatedAtByKey.current[key] = next.ts; + setThreadSnap(next); + writeCloudCacheThread(next); + } + } catch { + pushCloudTelemetry({ + event: "snapshot.fetch.error", + scopeKey, + workspaceId, + threadId, + fromCache: false, + durationMs: performance.now() - fetchStart, + note: "thread(select)", + }); + // ignore; poller will retry. + } + })(); + } + }, + [activeWorkspaceId, cloudEnabled, isCompact, runnerId, submitCommand, threadKey], + ); + + const handleSend = useCallback(async (text: string) => { + if (!canSend || !runnerId || !activeWorkspaceId || !activeThreadId) { + return; + } + const key = threadKey(activeWorkspaceId, activeThreadId); + const existingPending = pendingByThreadKey[key]; + if (existingPending && existingPending.phase !== "error") { + pushCloudTelemetry({ + event: "send.blocked", + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + note: existingPending.phase, + }); + return; + } + const trimmed = text.trim(); + if (!trimmed) return; + + const now = Date.now(); + const lastSend = lastSendRef.current; + if ( + lastSend && + lastSend.workspaceId === activeWorkspaceId && + lastSend.threadId === activeThreadId && + lastSend.text === trimmed && + now - lastSend.atMs < 1500 + ) { + return; + } + lastSendRef.current = { + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + text: trimmed, + atMs: now, + }; + + const commandId = crypto.randomUUID(); + const baselineAssistantCount = countAssistantMessages(activeItems); + setPendingByThreadKey((prev) => ({ + ...prev, + [key]: { id: commandId, createdAt: Date.now(), phase: "submitting" }, + })); + setAwaitingByThreadKey((prev) => ({ + ...prev, + [key]: { + commandId, + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + startedAtMs: Date.now(), + baselineAssistantCount, + }, + })); + setLocalItemsByThreadKey((prev) => ({ + ...prev, + [key]: [ + ...(prev[key] ?? []), + { + kind: "message", + id: `local-${commandId}-user`, + role: "user", + text: trimmed, + }, + ], + })); + try { + pushCloudTelemetry({ + event: "send.submit", + commandId, + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + note: trimmed, + }); + await cloudkitSubmitCommand( + runnerId, + JSON.stringify({ + commandId, + clientId, + type: "sendUserMessage", + args: { + workspaceId: activeWorkspaceId, + threadId: activeThreadId, + text: trimmed, + accessMode, + }, + }), + ); + setPendingByThreadKey((prev) => { + const entry = prev[key]; + if (!entry || entry.id !== commandId) return prev; + return { ...prev, [key]: { ...entry, phase: "waitingResult" } }; + }); + } catch (error) { + setPendingByThreadKey((prev) => ({ + ...prev, + [key]: { + id: commandId, + createdAt: Date.now(), + phase: "error", + error: error instanceof Error ? error.message : String(error), + }, + })); + setAwaitingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + } + }, [ + accessMode, + activeItems, + activeThreadId, + activeWorkspaceId, + canSend, + clientId, + countAssistantMessages, + pendingByThreadKey, + runnerId, + threadKey, + ]); + + useEffect(() => { + if (!runnerId) { + return; + } + let stopped = false; + const interval = window.setInterval(() => { + void (async () => { + if (stopped) return; + const entries = Object.entries(pendingByThreadKeyRef.current).filter( + ([, pending]) => pending.phase === "waitingResult", + ); + if (!entries.length) return; + + await Promise.all( + entries.map(async ([key, pending]) => { + const [workspaceId, threadId] = key.split("::"); + const fetchStart = performance.now(); + const result = await cloudkitGetCommandResult(runnerId, pending.id); + pushCloudTelemetry({ + event: "command.result.poll", + commandId: pending.id, + workspaceId, + threadId, + fromCache: false, + durationMs: performance.now() - fetchStart, + note: result ? (result.ok ? "ok" : "error") : "none", + }); + if (!result) return; + + if (!result.ok) { + setPendingByThreadKey((prev) => ({ + ...prev, + [key]: { + ...pending, + phase: "error", + error: result.payloadJson || "Command failed", + }, + })); + setAwaitingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + return; + } + + setPendingByThreadKey((prev) => { + const current = prev[key]; + if (!current || current.id !== pending.id) return prev; + return { + ...prev, + [key]: { ...current, phase: "waitingReply", resultPayloadJson: result.payloadJson ?? null }, + }; + }); + + // If the runner already extracted assistant text, show it immediately so the UI doesn't + // feel stuck while waiting for the next snapshot poll. + let assistantText = ""; + try { + const parsed = result.payloadJson ? (JSON.parse(result.payloadJson) as any) : null; + if (parsed && typeof parsed.assistantText === "string") { + assistantText = parsed.assistantText; + } + } catch { + // ignore + } + if (assistantText.trim()) { + const commandId = pending.id; + setLocalItemsByThreadKey((prev) => ({ + ...prev, + [key]: [ + ...(prev[key] ?? []), + { + kind: "message", + id: `local-${commandId}-assistant`, + role: "assistant", + text: assistantText, + }, + ], + })); + // Clear "working" for this thread immediately; the next snapshot will reconcile. + setAwaitingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + setPendingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + pushCloudTelemetry({ + event: "reply.seen", + fromCache: true, + workspaceId, + threadId, + commandId, + note: "assistantText(result)", + }); + } + }), + ); + })(); + }, 1500); + return () => { + stopped = true; + window.clearInterval(interval); + }; + }, [applyAwaitingResolutionFromItems, runnerId]); + + useEffect(() => { + const keys = Object.keys(awaitingByThreadKey); + if (keys.length === 0) return; + + const timeout = window.setInterval(() => { + const now = Date.now(); + Object.entries(awaitingByThreadKeyRef.current).forEach(([key, awaiting]) => { + if (now - awaiting.startedAtMs < 15 * 60_000) { + return; + } + pushCloudTelemetry({ + event: "reply.timeout", + fromCache: false, + workspaceId: awaiting.workspaceId, + threadId: awaiting.threadId, + commandId: awaiting.commandId, + }); + setAwaitingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + setPendingByThreadKey((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + }); + }, 10_000); + + return () => window.clearInterval(timeout); + }, [awaitingByThreadKey]); + + 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(activeWorkspaceId, 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 (Object.keys(pendingByThreadKey).length) return; + if (e2eThreadRequested.current) return; + + e2eThreadRequested.current = true; + window.setTimeout(() => { + if (!runnerOnline || !runnerId) return; + void submitCommand("startThread", { workspaceId: activeWorkspaceId }); + }, 1500); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, pendingByThreadKey, runnerId, runnerOnline, submitCommand, threads.length]); + + useEffect(() => { + if (!e2eEnabled || !cloudEnabled || !runnerOnline) return; + if (!activeWorkspaceId || !activeThreadId) return; + if (Object.keys(pendingByThreadKey).length) return; + if (!e2eBaseline.current) { + e2eBaseline.current = { + assistantCount: countAssistantMessages(activeItems), + }; + } + void handleSend("Erzähl mir einen kurzen Witz."); + }, [activeThreadId, activeWorkspaceId, cloudEnabled, e2eEnabled, handleSend, pendingByThreadKey, runnerOnline, countAssistantMessages, activeItems]); + + useEffect(() => { + if (!e2eEnabled || e2eCompleted.current) return; + if (!e2eBaseline.current) return; + if (!activeWorkspaceId || !activeThreadId) return; + const key = threadKey(activeWorkspaceId, activeThreadId); + if (awaitingByThreadKey[key] || pendingByThreadKey[key]) return; + + const currentAssistantCount = countAssistantMessages(activeItems); + if (currentAssistantCount <= e2eBaseline.current.assistantCount) return; + + e2eCompleted.current = true; + void e2eMark("success: received assistant response"); + window.setTimeout(() => void e2eQuit(), 750); + }, [activeItems, e2eEnabled, activeThreadId, activeWorkspaceId, awaitingByThreadKey, pendingByThreadKey, threadKey, countAssistantMessages]); + + 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 threadsByWorkspace = useMemo(() => { + const map: Record = {}; + Object.entries(workspaceSnaps).forEach(([workspaceId, snap]) => { + map[workspaceId] = snap.payload.threads ?? []; + }); + return map; + }, [workspaceSnaps]); + + const threadStatusById = useMemo(() => { + const merged: Record< + string, + { isProcessing: boolean; hasUnread: boolean; isReviewing: boolean } + > = {}; + Object.values(workspaceSnaps).forEach((snap) => { + Object.entries(snap.payload.threadStatusById ?? {}).forEach(([id, status]) => { + merged[id] = status; + }); + }); + // Cloud mode: mark threads as processing if this device has an in-flight command for them. + Object.entries(awaitingByThreadKey).forEach(([key]) => { + const [, threadId] = key.split("::"); + if (!threadId) return; + merged[threadId] = { ...(merged[threadId] ?? { hasUnread: false, isReviewing: false, isProcessing: false }), isProcessing: true }; + }); + Object.entries(pendingByThreadKey).forEach(([key, pending]) => { + const [, threadId] = key.split("::"); + if (!threadId) return; + if (pending.phase === "error") return; + merged[threadId] = { ...(merged[threadId] ?? { hasUnread: false, isReviewing: false, isProcessing: false }), isProcessing: true }; + }); + return merged; + }, [awaitingByThreadKey, pendingByThreadKey, workspaceSnaps]); + + const threadListLoadingByWorkspace = useMemo(() => { + const next: Record = {}; + workspaces.forEach((ws) => { + if (!runnerOnline) { + next[ws.id] = false; + } else { + next[ws.id] = workspaceSnaps[ws.id] == null; + } + }); + return next; + }, [runnerOnline, workspaceSnaps, workspaces]); + + useEffect(() => { + if (!isPhone) { + return; + } + if (!activeWorkspace && activeTab !== "projects") { + setActiveTab("projects"); + } + }, [activeTab, activeWorkspace, isPhone]); + + useEffect(() => { + if (!isTablet) { + return; + } + if (activeTab === "projects") { + setActiveTab("codex"); + } + }, [activeTab, isTablet]); + + const showHome = !activeWorkspace; + const isThinking = + Boolean(activeAwaiting) || + Boolean(activePending && activePending.phase !== "error") || + Boolean(activeThreadId && threadStatusById[activeThreadId]?.isProcessing); + + const threadListPagingByWorkspace = useMemo( + () => ({} as Record), + [], + ); + const threadListCursorByWorkspace = useMemo( + () => ({} as Record), + [], + ); + + const sidebarNode = ( + setSettingsOpen(true)} + onOpenDebug={() => { + alert("Debug view is not available in Cloud mode yet."); + }} + showDebugButton={false} + onAddWorkspace={() => { + alert("Add workspaces from the Mac app. The iOS app is read-only."); + }} + onSelectHome={() => { + setActiveWorkspaceId(null); + setActiveThreadId(null); + if (isCompact) { + setActiveTab("projects"); + } + }} + onSelectWorkspace={(workspaceId) => { + handleSelectWorkspace(workspaceId); + }} + onConnectWorkspace={(workspace) => { + void submitCommand("connectWorkspace", { workspaceId: workspace.id }); + }} + onAddAgent={(workspace) => { + setActiveWorkspaceId(workspace.id); + setActiveThreadId(null); + if (isCompact) { + setActiveTab("codex"); + } + void submitCommand("startThread", { workspaceId: workspace.id }); + }} + onAddWorktreeAgent={() => { + alert("Worktree agents are not available from iOS yet."); + }} + onToggleWorkspaceCollapse={() => {}} + onSelectThread={(workspaceId, threadId) => { + handleSelectThread(workspaceId, threadId); + }} + onDeleteThread={() => { + alert("Archiving threads from iOS is not available yet."); + }} + onDeleteWorkspace={() => { + alert("Workspace deletion is not available from iOS."); + }} + onDeleteWorktree={() => {}} + onLoadOlderThreads={() => {}} + /> + ); + + const messagesNode = ( + + ); + + const composerNode = ( + void handleSend(text)} + onStop={() => {}} + canStop={false} + disabled={!canSend || Boolean(activePending && activePending.phase !== "error")} + models={[]} + selectedModelId={null} + onSelectModel={() => {}} + reasoningOptions={[]} + selectedEffort={null} + onSelectEffort={() => {}} + accessMode={accessMode} + onSelectAccessMode={setAccessMode} + skills={[]} + prompts={[]} + files={[]} + /> + ); + + const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ + isPhone ? " layout-phone" : "" + }${isTablet ? " layout-tablet" : ""}${reduceTransparency ? " reduced-transparency" : ""}`; + + const tabletNavTab: "codex" | "git" | "log" = + tabletTab === "git" ? "git" : tabletTab === "log" ? "log" : "codex"; + + const tabletLayout = ( + <> + +
{sidebarNode}
+
+
+ {headerHint && ( +
+
Cloud
+
{headerHint}
+
+ )} + {runnerLabel && ( +
+
+ {runnerLabel} · {runnerOnline ? "online" : "offline"} +
+
+ )} + {showHome ? ( + + alert("Add workspaces from the Mac app. The iOS app is read-only.") + } + onAddWorkspace={() => + alert("Add workspaces from the Mac app. The iOS app is read-only.") + } + latestAgentRuns={[]} + isLoadingLatestAgents={false} + onSelectThread={() => {}} + /> + ) : ( + <> +
+
+ {}} + onCreateBranch={() => {}} + onToggleTerminal={() => {}} + isTerminalOpen={false} + showTerminalButton={false} + readonly + /> +
+
+
+ {tabletTab === "codex" && ( + <> +
{messagesNode}
+ {composerNode} + + )} + {tabletTab !== "codex" && ( +
+

Not available

+

This tab is not available in Cloud mode yet.

+
+ )} + + )} +
+ + ); + + const phoneLayout = ( +
+ {headerHint && ( +
+
Cloud
+
{headerHint}
+
+ )} + {runnerLabel && ( +
+
+ {runnerLabel} · {runnerOnline ? "online" : "offline"} +
+
+ )} + {activeTab === "projects" &&
{sidebarNode}
} + {activeTab === "codex" && ( +
+ {activeWorkspace ? ( + <> +
+
+ {}} + onCreateBranch={() => {}} + onToggleTerminal={() => {}} + isTerminalOpen={false} + showTerminalButton={false} + readonly + /> +
+
+
+
{messagesNode}
+ {composerNode} + + ) : ( +
+

No workspace selected

+

Choose a project to start chatting.

+ +
+ )} +
+ )} + {activeTab !== "projects" && activeTab !== "codex" && ( +
+
+

Not available

+

This tab is not available in Cloud mode yet.

+
+
+ )} + +
+ ); + + return ( +
+
+ {isPhone ? phoneLayout : isTablet ? tabletLayout : tabletLayout} + {settingsOpen ? ( + setSettingsOpen(false)} + onMoveWorkspace={() => {}} + onDeleteWorkspace={() => {}} + reduceTransparency={reduceTransparency} + onToggleTransparency={setReduceTransparency} + appSettings={appSettings} + onUpdateAppSettings={async (next) => { + await saveSettings(next); + }} + onRunDoctor={doctor} + onCloudKitStatus={cloudkitStatus} + onCloudKitTest={cloudkitTest} + onUpdateWorkspaceCodexBin={async () => {}} + scaleShortcutTitle="" + scaleShortcutText="" + onTestNotificationSound={() => {}} + /> + ) : null} +
+ ); +} diff --git a/src/features/app/components/MainHeader.tsx b/src/features/app/components/MainHeader.tsx index 4362136e1..fc02541fc 100644 --- a/src/features/app/components/MainHeader.tsx +++ b/src/features/app/components/MainHeader.tsx @@ -8,6 +8,7 @@ type MainHeaderProps = { parentName?: string | null; worktreeLabel?: string | null; disableBranchMenu?: boolean; + readonly?: boolean; parentPath?: string | null; worktreePath?: string | null; branchName: string; @@ -26,6 +27,7 @@ export function MainHeader({ parentName = null, worktreeLabel = null, disableBranchMenu = false, + readonly = false, parentPath = null, worktreePath = null, branchName, @@ -114,7 +116,13 @@ export function MainHeader({ - {disableBranchMenu ? ( + {readonly ? ( +
+
+ {branchName} +
+
+ ) : disableBranchMenu ? (
+ ))} {worktreeThreads.length > 3 && (
+ ))} {threads.length > 3 && (
)} {!items.length && ( -
- Start a thread and send a prompt to the agent. -
+ loadingMode === "loading" ? ( +
+
{loadingLabel || "Loading…"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : ( +
+ Start a thread and send a prompt to the agent. +
+ ) )}
diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 74e8c90d1..5931d5942 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,16 @@ import { Trash2, X, } from "lucide-react"; -import type { AppSettings, CodexDoctorResult, WorkspaceInfo } from "../../../types"; -import { - clampUiScale, -} from "../../../utils/uiScale"; +import type { CloudTelemetryEntry } from "../../../cloud/cloudTelemetry"; +import { clearCloudTelemetry, readCloudTelemetry } from "../../../cloud/cloudTelemetry"; +import type { + AppSettings, + CloudKitStatus, + CloudKitTestResult, + CodexDoctorResult, + WorkspaceInfo, +} from "../../../types"; +import { clampUiScale } from "../../../utils/uiScale"; type SettingsViewProps = { workspaces: WorkspaceInfo[]; @@ -25,14 +32,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 +58,61 @@ 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 [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"; 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 [cloudTelemetryOpen, setCloudTelemetryOpen] = useState(false); + const [cloudTelemetryEntries, setCloudTelemetryEntries] = useState< + CloudTelemetryEntry[] + >(() => readCloudTelemetry()); const [isSavingSettings, setIsSavingSettings] = useState(false); + const [isSavingCloudSettings, setIsSavingCloudSettings] = useState(false); const projects = useMemo(() => { return workspaces @@ -87,6 +135,44 @@ export function SettingsView({ setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); }, [appSettings.uiScale]); + useEffect(() => { + setCloudKitContainerDraft(appSettings.cloudKitContainerId ?? ""); + }, [appSettings.cloudKitContainerId]); + + useEffect(() => { + setCloudKitPollDraft( + appSettings.cloudKitPollIntervalMs + ? String(appSettings.cloudKitPollIntervalMs) + : "", + ); + }, [appSettings.cloudKitPollIntervalMs]); + + useEffect(() => { + if (activeSection !== "cloud") { + return; + } + if (!cloudTelemetryOpen) { + return; + } + setCloudTelemetryEntries(readCloudTelemetry()); + }, [activeSection, cloudTelemetryOpen]); + + 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 = {}; @@ -106,6 +192,17 @@ 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 cloudKitPollDirty = + (cloudKitPollDraft.trim() || null) !== + (appSettings.cloudKitPollIntervalMs ? String(appSettings.cloudKitPollIntervalMs) : null); const handleSaveCodexSettings = async () => { setIsSavingSettings(true); @@ -180,6 +277,134 @@ 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 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 { + 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 +438,14 @@ export function SettingsView({ Display & Sound + +
+ +
+ +
+ + setCloudKitContainerDraft(event.target.value) + } + /> +
+
+ Use the iCloud container identifier enabled for this app. Example:{" "} + iCloud.com.ilass.codexmonitor. +
+
+ +
+ +
+ setCloudKitPollDraft(event.target.value)} + inputMode="numeric" + /> +
+
+ Leave empty to use the default. Lower values mean faster remote control but more CloudKit traffic. +
+
+ +
+ {cloudKitContainerDirty && ( + + )} + {cloudKitPollDirty && ( + + )} + + +
+ + {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}
} +
+
+ )} + +
+ +
Cloud Telemetry
+
+ Client-side cache and fetch timings (local-only). Use this to see whether views were served from cache or fetched from CloudKit. +
+
+ + + +
+ {cloudTelemetryOpen && ( +
+
+                      {cloudTelemetryEntries.length
+                        ? cloudTelemetryEntries
+                            .slice(-120)
+                            .map((entry) => {
+                              const time = new Date(entry.ts).toLocaleTimeString();
+                              const parts = [
+                                time,
+                                entry.event,
+                                entry.fromCache != null ? `cache=${entry.fromCache}` : "",
+                                entry.durationMs != null ? `${Math.round(entry.durationMs)}ms` : "",
+                                entry.scopeKey ? `scope=${entry.scopeKey}` : "",
+                                entry.workspaceId ? `ws=${entry.workspaceId}` : "",
+                                entry.threadId ? `th=${entry.threadId}` : "",
+                                entry.note || "",
+                              ].filter(Boolean);
+                              return parts.join(" · ");
+                            })
+                            .join("\n")
+                        : "No telemetry yet."}
+                    
+
+ )} + +
+ +
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" && (
Codex
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 029bdbede..ee8a9070d 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -2,13 +2,31 @@ 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, - 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, + notificationSoundsEnabled: true, + }; +} function normalizeAppSettings(settings: AppSettings): AppSettings { return { @@ -18,7 +36,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(() => { @@ -27,9 +45,10 @@ export function useAppSettings() { try { const response = await getAppSettings(); if (active) { + const defaults = buildDefaultSettings(); setSettings( normalizeAppSettings({ - ...defaultSettings, + ...defaults, ...response, }), ); @@ -46,11 +65,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/features/update/hooks/useUpdater.ts b/src/features/update/hooks/useUpdater.ts index 08209aee6..9d510c008 100644 --- a/src/features/update/hooks/useUpdater.ts +++ b/src/features/update/hooks/useUpdater.ts @@ -35,6 +35,15 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { const [state, setState] = useState({ stage: "idle" }); const updateRef = useRef(null); + const isPermissionDenied = (message: string) => { + const lowered = message.toLowerCase(); + return ( + lowered.includes("updater.check not allowed") || + lowered.includes("updater_allow-check") || + lowered.includes("permissions associated with this command") + ); + }; + const resetToIdle = useCallback(async () => { const update = updateRef.current; updateRef.current = null; @@ -60,6 +69,12 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); + if (isPermissionDenied(message)) { + // Some platforms (notably iOS) do not expose updater capabilities. + // Treat this as "updater disabled" instead of surfacing an error dialog. + setState({ stage: "idle" }); + return; + } onDebug?.({ id: `${Date.now()}-client-updater-error`, timestamp: Date.now(), @@ -130,6 +145,10 @@ export function useUpdater({ enabled = true, onDebug }: UseUpdaterOptions) { } catch (error) { const message = error instanceof Error ? error.message : JSON.stringify(error); + if (isPermissionDenied(message)) { + setState({ stage: "idle" }); + return; + } onDebug?.({ id: `${Date.now()}-client-updater-error`, timestamp: Date.now(), diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 48eb8377c..b80c64089 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -2,6 +2,12 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { AppSettings, + CloudKitCommandAck, + CloudKitCommandResult, + CloudKitRunnerInfo, + CloudKitSnapshot, + CloudKitStatus, + CloudKitTestResult, CodexDoctorResult, WorkspaceInfo, WorkspaceSettings, @@ -197,12 +203,68 @@ 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 { 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 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/base.css b/src/styles/base.css index a46d0fc5b..2789c1260 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -86,8 +86,8 @@ --surface-context-core: rgba(16, 20, 30, 0.96); } - @media (prefers-color-scheme: light) { - :root { +@media (prefers-color-scheme: light) { + :root:not([data-platform="ios"]) { --text-primary: #1a1d24; --text-strong: #0e1118; --text-emphasis: rgba(17, 20, 28, 0.9); @@ -109,22 +109,22 @@ --surface-item: rgba(255, 255, 255, 0.6); --surface-control: rgba(15, 23, 36, 0.08); --surface-control-hover: rgba(15, 23, 36, 0.12); - --surface-control-disabled: rgba(15, 23, 36, 0.05); - --surface-hover: rgba(15, 23, 36, 0.06); - --surface-active: rgba(77, 153, 255, 0.18); + --surface-control-disabled: rgba(15, 23, 36, 0.05); + --surface-hover: rgba(15, 23, 36, 0.06); + --surface-active: rgba(77, 153, 255, 0.18); --surface-approval: rgba(246, 248, 252, 0.92); --surface-debug: rgba(242, 244, 248, 0.9); --surface-command: rgba(245, 247, 250, 0.95); --surface-diff-card: rgba(240, 243, 248, 0.92); - --surface-bubble: rgba(255, 255, 255, 0.9); - --surface-bubble-user: rgba(77, 153, 255, 0.22); - --surface-context-core: rgba(255, 255, 255, 0.9); - --surface-review: rgba(255, 170, 210, 0.25); - --border-review: rgba(200, 90, 140, 0.45); - --surface-review-active: rgba(255, 170, 210, 0.32); - --text-review-active: rgba(120, 30, 70, 0.9); - --surface-review-done: rgba(140, 235, 200, 0.35); - --text-review-done: rgba(20, 90, 60, 0.9); + --surface-bubble: rgba(255, 255, 255, 0.9); + --surface-bubble-user: rgba(77, 153, 255, 0.22); + --surface-context-core: rgba(255, 255, 255, 0.9); + --surface-review: rgba(255, 170, 210, 0.25); + --border-review: rgba(200, 90, 140, 0.45); + --surface-review-active: rgba(255, 170, 210, 0.32); + --text-review-active: rgba(120, 30, 70, 0.9); + --surface-review-done: rgba(140, 235, 200, 0.35); + --text-review-done: rgba(20, 90, 60, 0.9); --border-subtle: rgba(15, 23, 36, 0.08); --border-muted: rgba(15, 23, 36, 0.06); --border-strong: rgba(15, 23, 36, 0.14); @@ -132,16 +132,16 @@ --border-quiet: rgba(15, 23, 36, 0.2); --border-accent: rgba(77, 153, 255, 0.5); --border-accent-soft: rgba(77, 153, 255, 0.28); - --text-accent: rgba(45, 93, 170, 0.7); - --shadow-accent: rgba(90, 140, 210, 0.18); - --status-success: rgba(30, 155, 110, 0.9); + --text-accent: rgba(45, 93, 170, 0.7); + --shadow-accent: rgba(90, 140, 210, 0.18); + --status-success: rgba(30, 155, 110, 0.9); --status-warning: rgba(215, 120, 20, 0.9); --status-error: rgba(200, 45, 45, 0.9); --status-unknown: rgba(17, 20, 28, 0.25); - --select-caret: rgba(15, 23, 36, 0.45); + --select-caret: rgba(15, 23, 36, 0.45); } - .app.reduced-transparency { + :root:not([data-platform="ios"]) .app.reduced-transparency { --surface-sidebar: rgba(240, 242, 247, 0.98); --surface-topbar: rgba(244, 246, 250, 0.98); --surface-right-panel: rgba(242, 244, 248, 0.98); @@ -177,6 +177,11 @@ body { overflow: hidden; } +html[data-platform="ios"], +html[data-platform="ios"] body { + background: rgba(8, 10, 16, 1); +} + #root { height: 100vh; overflow: hidden; diff --git a/src/styles/cloud-client.css b/src/styles/cloud-client.css new file mode 100644 index 000000000..0f7203b45 --- /dev/null +++ b/src/styles/cloud-client.css @@ -0,0 +1,126 @@ +.cloud-client { + height: 100vh; + display: flex; + flex-direction: column; +} + +.cloud-client-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; +} + +.cloud-client-title { + font-size: 16px; + font-weight: 600; +} + +.cloud-client-hint, +.cloud-client-runner { + padding: 0 14px 8px; + font-size: 12px; + opacity: 0.8; +} + +.cloud-client-projects { + flex: 1; + overflow: auto; + padding: 10px 12px 72px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cloud-client-workspace { + text-align: left; + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.cloud-client-workspace.active { + border-color: rgba(73, 161, 255, 0.5); +} + +.cloud-client-workspace-name { + font-weight: 600; + margin-bottom: 2px; +} + +.cloud-client-workspace-sub { + font-size: 12px; + opacity: 0.8; +} + +.cloud-client-chat { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.cloud-client-threadbar { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.cloud-client-actions { + display: flex; + justify-content: flex-end; + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.cloud-client-thread { + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; + font-size: 12px; +} + +.cloud-client-thread.active { + border-color: rgba(73, 161, 255, 0.5); +} + +.cloud-client-messages { + flex: 1; + overflow: auto; +} + +.cloud-client-composer { + padding: 10px; + display: flex; + gap: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + align-items: flex-end; +} + +.cloud-client-input { + flex: 1; + resize: none; + border-radius: 10px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.12); + color: inherit; +} + +.cloud-client-error { + position: absolute; + margin-top: -26px; + font-size: 12px; + color: #ff6b6b; +} + +.cloud-client-empty { + opacity: 0.8; + font-size: 13px; + padding: 14px; +} diff --git a/src/styles/main.css b/src/styles/main.css index 1b6844d2a..d99588f08 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -325,6 +325,15 @@ color: var(--text-stronger); } +.workspace-branch-button.is-readonly { + cursor: default; +} + +.workspace-branch-button.is-readonly:hover { + background: transparent; + color: var(--text-subtle); +} + .workspace-branch-caret { font-size: 11px; color: var(--text-faint); diff --git a/src/styles/messages.css b/src/styles/messages.css index 251146a43..dfa41c113 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -73,6 +73,57 @@ animation: working-shimmer 2.2s ease-in-out infinite; } +.messages-syncing { + margin: 10px 24px 12px 24px; +} + +.messages-loading { + padding: 18px 24px 18px 32px; +} + +.messages-loading-label { + font-size: 12px; + color: var(--text-fainter); + margin-bottom: 14px; + letter-spacing: 0.02em; +} + +.messages-loading-skeleton { + display: flex; + flex-direction: column; + gap: 14px; +} + +.messages-loading-row { + display: flex; +} + +.messages-loading-row.left { + justify-content: flex-start; +} + +.messages-loading-row.right { + justify-content: flex-end; +} + +.messages-loading-bubble { + width: min(320px, 62%); + height: 38px; + border-radius: 14px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.06), + rgba(255, 255, 255, 0.16), + rgba(255, 255, 255, 0.06) + ); + background-size: 200% 100%; + animation: working-shimmer 2.6s ease-in-out infinite; +} + +.messages-loading-bubble.wide { + width: min(420px, 70%); +} + .turn-complete { display: flex; align-items: center; 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/styles/sidebar.css b/src/styles/sidebar.css index 96b86da50..1e81b2d3f 100644 --- a/src/styles/sidebar.css +++ b/src/styles/sidebar.css @@ -327,11 +327,15 @@ gap: 6px; padding: 4px 6px; border-radius: 6px; + border: none; + background: transparent; color: var(--text-quiet); font-size: 12px; cursor: pointer; -webkit-app-region: no-drag; min-width: 0; + width: 100%; + text-align: left; } .thread-status { @@ -364,9 +368,11 @@ box-shadow: 0 0 8px rgba(63, 228, 126, 0.5); } -.thread-row:hover { - background: var(--surface-hover); - color: var(--text-strong); +@media (hover: hover) and (pointer: fine) { + .thread-row:hover { + background: var(--surface-hover); + color: var(--text-strong); + } } .thread-row.active { @@ -406,9 +412,11 @@ color: var(--text-strong); } -.thread-row:hover .thread-menu-trigger { - opacity: 1; - pointer-events: auto; +@media (hover: hover) and (pointer: fine) { + .thread-row:hover .thread-menu-trigger { + opacity: 1; + pointer-events: auto; + } } .thread-more { diff --git a/src/threads/threadItems.ts b/src/threads/threadItems.ts new file mode 100644 index 000000000..c02335ca3 --- /dev/null +++ b/src/threads/threadItems.ts @@ -0,0 +1,206 @@ +import type { ConversationItem } from "../types"; + +function asString(value: unknown) { + return typeof value === "string" ? value : value ? String(value) : ""; +} + +function userInputsToText(inputs: Array>) { + return inputs + .map((input) => { + const type = asString(input.type); + if (type === "text") { + return asString(input.text); + } + if (type === "skill") { + const name = asString(input.name); + return name ? `$${name}` : ""; + } + if (type === "image" || type === "localImage") { + return "[image]"; + } + return ""; + }) + .filter(Boolean) + .join(" ") + .trim(); +} + +function buildConversationItem(item: Record): ConversationItem | null { + const type = asString(item.type); + const id = asString(item.id); + if (!id || !type) { + return null; + } + if (type === "agentMessage" || type === "userMessage") { + return null; + } + if (type === "reasoning") { + const summary = asString(item.summary ?? ""); + const content = Array.isArray(item.content) + ? item.content.map((entry) => asString(entry)).join("\n") + : asString(item.content ?? ""); + return { id, kind: "reasoning", summary, content }; + } + if (type === "commandExecution") { + const command = Array.isArray(item.command) + ? item.command.map((part) => asString(part)).join(" ") + : asString(item.command ?? ""); + return { + id, + kind: "tool", + toolType: type, + title: command ? `Command: ${command}` : "Command", + detail: asString(item.cwd ?? ""), + status: asString(item.status ?? ""), + output: asString(item.aggregatedOutput ?? ""), + }; + } + if (type === "fileChange") { + const changes = Array.isArray(item.changes) ? item.changes : []; + const normalizedChanges = changes + .map((change) => { + const path = asString(change?.path ?? ""); + const kind = change?.kind as Record | string | undefined; + const kindType = + typeof kind === "string" + ? kind + : typeof kind === "object" && kind + ? asString((kind as Record).type ?? "") + : ""; + const normalizedKind = kindType ? kindType.toLowerCase() : ""; + const diff = asString(change?.diff ?? ""); + return { path, kind: normalizedKind || undefined, diff: diff || undefined }; + }) + .filter((change) => change.path); + const formattedChanges = normalizedChanges + .map((change) => { + const prefix = + change.kind === "add" + ? "A" + : change.kind === "delete" + ? "D" + : change.kind + ? "M" + : ""; + return [prefix, change.path].filter(Boolean).join(" "); + }) + .filter(Boolean); + const paths = formattedChanges.join(", "); + const diffOutput = normalizedChanges + .map((change) => change.diff ?? "") + .filter(Boolean) + .join("\n\n"); + return { + id, + kind: "tool", + toolType: type, + title: "File changes", + detail: paths || "Pending changes", + status: asString(item.status ?? ""), + output: diffOutput, + changes: normalizedChanges, + }; + } + if (type === "mcpToolCall") { + const server = asString(item.server ?? ""); + const tool = asString(item.tool ?? ""); + const args = item.arguments ? JSON.stringify(item.arguments, null, 2) : ""; + return { + id, + kind: "tool", + toolType: type, + title: `Tool: ${server}${tool ? ` / ${tool}` : ""}`, + detail: args, + status: asString(item.status ?? ""), + output: asString(item.result ?? item.error ?? ""), + }; + } + if (type === "webSearch") { + return { + id, + kind: "tool", + toolType: type, + title: "Web search", + detail: asString(item.query ?? ""), + status: "", + output: "", + }; + } + if (type === "imageView") { + return { + id, + kind: "tool", + toolType: type, + title: "Image view", + detail: asString(item.path ?? ""), + status: "", + output: "", + }; + } + if (type === "enteredReviewMode" || type === "exitedReviewMode") { + return { + id, + kind: "review", + state: type === "enteredReviewMode" ? "started" : "completed", + text: asString(item.review ?? ""), + }; + } + return null; +} + +function buildConversationItemFromThreadItem( + item: Record, +): ConversationItem | null { + const type = asString(item.type); + const id = asString(item.id); + if (!id || !type) { + return null; + } + if (type === "userMessage") { + const content = Array.isArray(item.content) ? item.content : []; + const text = userInputsToText(content as Record[]); + return { + id, + kind: "message", + role: "user", + text: text || "[message]", + }; + } + if (type === "agentMessage") { + return { + id, + kind: "message", + role: "assistant", + text: asString(item.text), + }; + } + if (type === "reasoning") { + const summary = Array.isArray(item.summary) + ? item.summary.map((entry) => asString(entry)).join("\n") + : asString(item.summary ?? ""); + const content = Array.isArray(item.content) + ? item.content.map((entry) => asString(entry)).join("\n") + : asString(item.content ?? ""); + return { id, kind: "reasoning", summary, content }; + } + return buildConversationItem(item); +} + +export function buildItemsFromThread(thread: Record) { + const turns = Array.isArray(thread.turns) ? thread.turns : []; + const items: ConversationItem[] = []; + turns.forEach((turn) => { + const turnRecord = turn as Record; + const turnItems = Array.isArray(turnRecord.items) + ? (turnRecord.items as Record[]) + : []; + turnItems.forEach((item) => { + const converted = buildConversationItemFromThreadItem(item); + if (converted) { + items.push(converted); + } + }); + }); + return items; +} + diff --git a/src/types.ts b/src/types.ts index a4195b8f0..a30a59621 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,6 +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; @@ -80,6 +92,40 @@ export type CodexDoctorResult = { nodeDetails: string | null; }; +export type CloudKitStatus = { + available: boolean; + status: string; +}; + +export type CloudKitTestResult = { + recordName: string; + 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; diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 000000000..9330760c7 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,20 @@ +export function isAppleMobile(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + // 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; +}