From 72a9236ad64def2a08fb668d60a7b06a17669c0e Mon Sep 17 00:00:00 2001 From: Peter vogel Date: Sun, 18 Jan 2026 06:02:21 +0100 Subject: [PATCH 1/5] feat(mobile): remote runner via NATS + iPad E2E --- .gitignore | 7 + scripts/ios-e2e-joke-device.sh | 55 ++ scripts/ios-ui-screenshot-sim.sh | 75 ++ scripts/macos-runner-dev-bg.sh | 27 + src-tauri/Cargo.lock | 386 +++++++++- src-tauri/Cargo.toml | 11 +- src-tauri/capabilities/default.json | 1 + src-tauri/capabilities/mobile.json | 13 + src-tauri/src/backend/events.rs | 4 +- src-tauri/src/dictation_mobile.rs | 123 +++ src-tauri/src/event_sink.rs | 4 +- src-tauri/src/git_mobile.rs | 96 +++ src-tauri/src/integrations/mod.rs | 719 ++++++++++++++++++ src-tauri/src/integrations/nats.rs | 232 ++++++ src-tauri/src/lib.rs | 46 +- src-tauri/src/settings.rs | 5 +- src-tauri/src/state.rs | 37 +- src-tauri/src/terminal_mobile.rs | 63 ++ src-tauri/src/types.rs | 85 ++- src-tauri/src/workspaces.rs | 2 +- src/App.tsx | 271 ++++++- .../cloudClient/components/CloudClientApp.tsx | 345 +++++++++ .../cloudClient/hooks/useCloudClient.ts | 304 ++++++++ .../composer/components/ComposerInput.tsx | 1 + src/features/home/components/Home.tsx | 20 +- src/features/layout/hooks/useLayoutNodes.tsx | 14 +- .../settings/components/SettingsView.tsx | 369 ++++++++- src/features/settings/hooks/useAppSettings.ts | 15 + src/features/update/hooks/useUpdater.ts | 29 +- .../workspaces/hooks/useWorkspaces.ts | 11 + src/services/tauri.ts | 216 +++++- src/styles/base.css | 111 ++- src/styles/cloud-client.css | 354 +++++++++ src/styles/home.css | 23 + src/styles/settings.css | 24 + src/types.ts | 28 + src/utils/platform.ts | 16 + 37 files changed, 4086 insertions(+), 56 deletions(-) create mode 100755 scripts/ios-e2e-joke-device.sh create mode 100755 scripts/ios-ui-screenshot-sim.sh create mode 100755 scripts/macos-runner-dev-bg.sh create mode 100644 src-tauri/capabilities/mobile.json create mode 100644 src-tauri/src/dictation_mobile.rs create mode 100644 src-tauri/src/git_mobile.rs create mode 100644 src-tauri/src/integrations/mod.rs create mode 100644 src-tauri/src/integrations/nats.rs create mode 100644 src-tauri/src/terminal_mobile.rs create mode 100644 src/features/cloudClient/components/CloudClientApp.tsx create mode 100644 src/features/cloudClient/hooks/useCloudClient.ts create mode 100644 src/styles/cloud-client.css create mode 100644 src/utils/platform.ts diff --git a/.gitignore b/.gitignore index cc60eeaaf..7bbf14a12 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,10 @@ dist-ssr CodexMonitor.zip .codex-worktrees/ .codexmonitor/ +.codexmonitor-logs/ +ilass-private/ +.codexmonitor-artifacts/ +src-tauri/gen/ +codex-monitor-01.png +codex-monitor-IST.png +screen_random_32342.png diff --git a/scripts/ios-e2e-joke-device.sh b/scripts/ios-e2e-joke-device.sh new file mode 100755 index 000000000..3b9623a82 --- /dev/null +++ b/scripts/ios-e2e-joke-device.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_NAME="${1:-iPad von Peter (2)}" + +export PATH="$HOME/.cargo/bin:$PATH" +export VITE_E2E=1 + +APPLE_TEAM_DEFAULT="ZAMR4EWP34" +export APPLE_DEVELOPMENT_TEAM="${APPLE_DEVELOPMENT_TEAM:-$APPLE_TEAM_DEFAULT}" +APP_BUNDLE_ID="${APP_BUNDLE_ID:-com.ilass.codexmonitor}" + +echo "[ios-e2e] Device: ${DEVICE_NAME}" +echo "[ios-e2e] Team: ${APPLE_DEVELOPMENT_TEAM}" +echo "[ios-e2e] Building (debug, device aarch64) with VITE_E2E=1..." +rm -rf "${PWD}/src-tauri/gen/apple/build" || true +if [[ ! -d "${PWD}/src-tauri/gen/apple" ]]; then + npx tauri ios init +fi +npx tauri ios build --debug --target aarch64 + +echo "[ios-e2e] Looking for CodexMonitor.app (debug-iphoneos)..." +UDID="$( + xcrun xctrace list devices 2>/dev/null \ + | grep -F "${DEVICE_NAME}" \ + | head -n 1 \ + | sed -n 's/.*(\([0-9a-fA-F-]\{8,\}\)).*/\1/p' +)" +if [[ -z "${UDID}" ]]; then + echo "[ios-e2e] ERROR: Could not find UDID for device: ${DEVICE_NAME}" >&2 + exit 1 +fi + +APP_PATH="$( + find "$HOME/Library/Developer/Xcode/DerivedData" \ + -type d \ + -path '*/Build/Products/debug-iphoneos/CodexMonitor.app' \ + -print0 \ + | xargs -0 ls -td 2>/dev/null \ + | head -n 1 \ + || true +)" + +if [[ -z "${APP_PATH}" ]]; then + echo "[ios-e2e] ERROR: Could not find built CodexMonitor.app in DerivedData." >&2 + exit 1 +fi + +echo "[ios-e2e] Installing app: ${APP_PATH}" +xcrun devicectl device install app --device "${UDID}" "${APP_PATH}" + +echo "[ios-e2e] Launching bundle id: ${APP_BUNDLE_ID}" +xcrun devicectl device process launch --terminate-existing --device "${UDID}" "${APP_BUNDLE_ID}" + +echo "[ios-e2e] Launched. The app should auto-run the E2E joke test (Status should become PASS)." diff --git a/scripts/ios-ui-screenshot-sim.sh b/scripts/ios-ui-screenshot-sim.sh new file mode 100755 index 000000000..44949b87d --- /dev/null +++ b/scripts/ios-ui-screenshot-sim.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +SIM_NAME="${1:-iPad (A16)}" + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ART_DIR="${ROOT_DIR}/.codexmonitor-artifacts/ios" +mkdir -p "${ART_DIR}" + +export PATH="$HOME/.cargo/bin:$PATH" +export VITE_E2E="${VITE_E2E:-1}" +APP_BUNDLE_ID="${APP_BUNDLE_ID:-com.ilass.codexmonitor}" +WAIT_SECS="${WAIT_SECS:-18}" + +echo "[ios-sim-shot] Simulator: ${SIM_NAME}" + +SIM_LINE="$(xcrun simctl list devices | grep -F "${SIM_NAME} (" | head -n 1 || true)" +if [[ -z "${SIM_LINE}" ]]; then + echo "[ios-sim-shot] ERROR: simulator not found: ${SIM_NAME}" >&2 + exit 1 +fi +SIM_UDID="$(echo "${SIM_LINE}" | sed -n 's/.*(\([0-9A-Fa-f-]\{8,\}\)).*/\1/p')" +if [[ -z "${SIM_UDID}" ]]; then + echo "[ios-sim-shot] ERROR: failed to parse simulator UDID." >&2 + exit 1 +fi + +echo "[ios-sim-shot] UDID: ${SIM_UDID}" +xcrun simctl boot "${SIM_UDID}" >/dev/null 2>&1 || true +xcrun simctl bootstatus "${SIM_UDID}" -b + +echo "[ios-sim-shot] Building (aarch64-sim)..." +rm -rf "${ROOT_DIR}/src-tauri/gen/apple/build" || true +if [[ ! -d "${ROOT_DIR}/src-tauri/gen/apple" ]]; then + (cd "${ROOT_DIR}" && npx tauri ios init) +fi +(cd "${ROOT_DIR}" && npx tauri ios build --debug --target aarch64-sim) + +APP_PATH="$( + find "$HOME/Library/Developer/Xcode/DerivedData" \ + -type d \ + \( -path '*/Build/Products/Debug-iphonesimulator/CodexMonitor.app' -o -path '*/Build/Products/debug-iphonesimulator/CodexMonitor.app' \) \ + -print0 \ + | xargs -0 ls -td 2>/dev/null \ + | head -n 1 \ + || true +)" +if [[ -z "${APP_PATH}" ]]; then + echo "[ios-sim-shot] ERROR: could not find built CodexMonitor.app (iphonesimulator)." >&2 + exit 1 +fi + +echo "[ios-sim-shot] Installing: ${APP_PATH}" +xcrun simctl uninstall "${SIM_UDID}" "${APP_BUNDLE_ID}" >/dev/null 2>&1 || true +xcrun simctl install "${SIM_UDID}" "${APP_PATH}" + +echo "[ios-sim-shot] Launching..." +STAMP="$(date +%Y%m%d-%H%M%S)" +STDOUT_LOG="${ART_DIR}/sim-${SIM_UDID}-${STAMP}.stdout.log" +STDERR_LOG="${ART_DIR}/sim-${SIM_UDID}-${STAMP}.stderr.log" +xcrun simctl launch \ + --terminate-running-process \ + --stdout="${STDOUT_LOG}" \ + --stderr="${STDERR_LOG}" \ + "${SIM_UDID}" \ + "${APP_BUNDLE_ID}" \ + >/dev/null || true + +sleep "${WAIT_SECS}" + +OUT="${ART_DIR}/sim-${SIM_UDID}-${STAMP}.png" +echo "[ios-sim-shot] Screenshot: ${OUT}" +xcrun simctl io "${SIM_UDID}" screenshot "${OUT}" + +echo "[ios-sim-shot] Done." diff --git a/scripts/macos-runner-dev-bg.sh b/scripts/macos-runner-dev-bg.sh new file mode 100755 index 000000000..28a3197aa --- /dev/null +++ b/scripts/macos-runner-dev-bg.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="${ROOT_DIR}/.codexmonitor-logs" +PID_FILE="${LOG_DIR}/macos-runner.pid" +LOG_FILE="${LOG_DIR}/macos-runner.log" + +mkdir -p "${LOG_DIR}" + +if [[ -f "${PID_FILE}" ]]; then + PID="$(cat "${PID_FILE}" || true)" + if [[ -n "${PID}" ]] && kill -0 "${PID}" 2>/dev/null; then + echo "[runner] Already running (pid=${PID}). Logs: ${LOG_FILE}" + exit 0 + fi +fi + +export PATH="$HOME/.cargo/bin:$PATH" + +echo "[runner] Starting macOS runner in background..." +echo "[runner] Logs: ${LOG_FILE}" + +nohup bash -c "export PATH=\"$HOME/.cargo/bin:\$PATH\"; cd \"${ROOT_DIR}\" && npm run tauri dev" >"${LOG_FILE}" 2>&1 & +echo $! > "${PID_FILE}" + +echo "[runner] Started (pid=$(cat "${PID_FILE}"))." diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 73f8756b8..ff8207211 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -145,6 +145,42 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-nats" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f6da6d49a956424ca4e28fe93656f790d748b469eaccbc7488fec545315180" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand 0.8.5", + "regex", + "ring", + "rustls-native-certs", + "rustls-pemfile", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + [[package]] name = "async-process" version = "2.5.0" @@ -256,6 +292,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.69.5" @@ -566,10 +608,12 @@ dependencies = [ name = "codex-monitor" version = "0.1.0" dependencies = [ + "async-nats", "block2", "chrono", "cpal", "fix-path-env", + "futures-util", "git2", "ignore", "libc", @@ -589,6 +633,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-updater", "tokio", + "url", "uuid", "whisper-rs", ] @@ -612,6 +657,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.4.0" @@ -628,6 +679,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -651,7 +712,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -664,7 +725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -810,6 +871,32 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling" version = "0.21.3" @@ -851,6 +938,23 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -1010,6 +1114,28 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "signature", + "subtle", +] + [[package]] name = "either" version = "1.15.0" @@ -1126,6 +1252,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1243,6 +1375,20 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1250,6 +1396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1317,6 +1464,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1791,7 +1939,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.5", ] [[package]] @@ -2541,6 +2689,21 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.16", + "log", + "rand 0.8.5", + "signatory", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2557,6 +2720,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3070,6 +3242,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3210,6 +3391,26 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3233,6 +3434,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3279,6 +3490,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + [[package]] name = "portable-pty" version = "0.8.1" @@ -3719,7 +3936,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 1.0.5", ] [[package]] @@ -3816,11 +4033,33 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.13.2" @@ -3831,6 +4070,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.8" @@ -3863,6 +4112,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3920,6 +4178,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4014,6 +4295,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -4203,6 +4493,28 @@ dependencies = [ "libc", ] +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -4291,6 +4603,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4417,7 +4739,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -4954,9 +5276,21 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4980,6 +5314,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.5", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + [[package]] name = "toml" version = "0.8.2" @@ -5180,6 +5535,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5533,6 +5898,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + [[package]] name = "webpki-roots" version = "1.0.5" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ff5aeb3b..6ad678be9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,12 +27,19 @@ serde_json = "1" tokio = { version = "1", features = ["fs", "net", "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" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } libc = "0.2" +async-nats = "0.42" +futures-util = "0.3" +url = "2" + +[target."cfg(not(target_os = \"ios\"))".dependencies] +git2 = "0.20.3" +portable-pty = "0.8" +cpal = "0.15" +whisper-rs = "0.12" chrono = { version = "0.4", features = ["clock"] } [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 543413aad..25a758c2f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,6 +3,7 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main", "about"], + "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", "opener:default", diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json new file mode 100644 index 000000000..c4aba22de --- /dev/null +++ b/src-tauri/capabilities/mobile.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "Capability for mobile builds (no updater).", + "windows": ["main"], + "platforms": ["iOS", "android"], + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "process:default" + ] +} diff --git a/src-tauri/src/backend/events.rs b/src-tauri/src/backend/events.rs index 69da89ae4..93af497a5 100644 --- a/src-tauri/src/backend/events.rs +++ b/src-tauri/src/backend/events.rs @@ -1,7 +1,7 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Serialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct AppServerEvent { pub(crate) workspace_id: String, pub(crate) message: Value, diff --git a/src-tauri/src/dictation_mobile.rs b/src-tauri/src/dictation_mobile.rs new file mode 100644 index 000000000..c1c4f9bec --- /dev/null +++ b/src-tauri/src/dictation_mobile.rs @@ -0,0 +1,123 @@ +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::state::AppState; + +fn unsupported() -> String { + "Dictation is not supported on mobile builds.".to_string() +} + +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DictationModelState { + Missing, + Downloading, + Ready, + Error, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct DictationDownloadProgress { + #[serde(rename = "downloadedBytes")] + pub(crate) downloaded_bytes: u64, + #[serde(rename = "totalBytes")] + pub(crate) total_bytes: Option, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct DictationModelStatus { + pub(crate) state: DictationModelState, + #[serde(rename = "modelId")] + pub(crate) model_id: String, + pub(crate) progress: Option, + pub(crate) error: Option, + pub(crate) path: Option, +} + +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DictationSessionState { + Idle, + Listening, + Processing, +} + +pub(crate) struct DictationState { + pub(crate) model_status: DictationModelStatus, + pub(crate) session_state: DictationSessionState, +} + +impl Default for DictationState { + fn default() -> Self { + Self { + model_status: DictationModelStatus { + state: DictationModelState::Missing, + model_id: "base".to_string(), + progress: None, + error: None, + path: None, + }, + session_state: DictationSessionState::Idle, + } + } +} + +#[tauri::command] +pub(crate) async fn dictation_model_status( + _app: AppHandle, + _state: State<'_, AppState>, + _model_id: Option, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_download_model( + _app: AppHandle, + _state: State<'_, AppState>, + _model_id: Option, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_cancel_download( + _app: AppHandle, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_remove_model( + _app: AppHandle, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_start( + _preferred_language: Option, + _app: AppHandle, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_stop( + _app: AppHandle, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn dictation_cancel( + _app: AppHandle, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + diff --git a/src-tauri/src/event_sink.rs b/src-tauri/src/event_sink.rs index d1933346a..4f1cac31c 100644 --- a/src-tauri/src/event_sink.rs +++ b/src-tauri/src/event_sink.rs @@ -1,6 +1,7 @@ use tauri::{AppHandle, Emitter}; use crate::backend::events::{AppServerEvent, EventSink, TerminalOutput}; +use crate::integrations; #[derive(Clone)] pub(crate) struct TauriEventSink { @@ -15,7 +16,8 @@ impl TauriEventSink { impl EventSink for TauriEventSink { fn emit_app_server_event(&self, event: AppServerEvent) { - let _ = self.app.emit("app-server-event", event); + let _ = self.app.emit("app-server-event", event.clone()); + integrations::try_emit_app_server_event(&self.app, event); } fn emit_terminal_output(&self, event: TerminalOutput) { diff --git a/src-tauri/src/git_mobile.rs b/src-tauri/src/git_mobile.rs new file mode 100644 index 000000000..d27d45176 --- /dev/null +++ b/src-tauri/src/git_mobile.rs @@ -0,0 +1,96 @@ +use tauri::State; + +use crate::state::AppState; +use crate::types::{ + GitFileDiff, GitHubIssuesResponse, GitHubPullRequestDiff, GitHubPullRequestsResponse, + GitLogResponse, +}; + +fn unsupported() -> String { + "Git features are not supported on mobile builds.".to_string() +} + +#[tauri::command] +pub(crate) async fn get_git_status( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_git_diffs( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result, String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_git_log( + _workspace_id: String, + _limit: Option, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_git_remote( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result, String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_github_issues( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_github_pull_requests( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn get_github_pull_request_diff( + _workspace_id: String, + _pr_number: u64, + _state: State<'_, AppState>, +) -> Result, String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn list_git_branches( + _workspace_id: String, + _state: State<'_, AppState>, +) -> Result { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn checkout_git_branch( + _workspace_id: String, + _name: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn create_git_branch( + _workspace_id: String, + _name: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs new file mode 100644 index 000000000..5f7a24b70 --- /dev/null +++ b/src-tauri/src/integrations/mod.rs @@ -0,0 +1,719 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::backend::events::AppServerEvent; +use crate::state::AppState; +use crate::types::CloudProvider; + +mod nats; + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct NatsStatus { + pub(crate) ok: bool, + pub(crate) server: Option, + pub(crate) error: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct CloudKitStatus { + pub(crate) available: bool, + pub(crate) status: String, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct CloudKitTestResult { + #[serde(rename = "recordName")] + pub(crate) record_name: String, + #[serde(rename = "durationMs")] + pub(crate) duration_ms: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +struct RpcRequest { + id: Value, + method: String, + #[serde(default)] + params: Value, +} + +#[derive(Debug, Deserialize, Serialize)] +struct RpcError { + message: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct RpcResponse { + id: Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +impl RpcResponse { + fn ok(id: Value, result: Value) -> Self { + Self { + id, + result: Some(result), + error: None, + } + } + + fn err(id: Value, message: String) -> Self { + Self { + id, + result: None, + error: Some(RpcError { message }), + } + } +} + +pub(crate) struct IntegrationsRuntime { + cloud: Option, + cloud_listener: Option, +} + +pub(crate) struct CloudRuntime { + provider: CloudProvider, + config: Option, + event_tx: mpsc::UnboundedSender, + handle: JoinHandle<()>, +} + +pub(crate) struct CloudListenerRuntime { + provider: CloudProvider, + runner_id: String, + config: Option, + handle: JoinHandle<()>, +} + +impl Default for IntegrationsRuntime { + fn default() -> Self { + Self { + cloud: None, + cloud_listener: None, + } + } +} + +impl IntegrationsRuntime { + fn stop_cloud(&mut self) { + if let Some(runtime) = self.cloud.take() { + runtime.handle.abort(); + } + } + + fn stop_cloud_listener(&mut self) { + if let Some(runtime) = self.cloud_listener.take() { + runtime.handle.abort(); + } + } +} + +#[cfg(mobile)] +pub(crate) async fn apply_settings(app: AppHandle) { + let state = app.state::(); + let provider = state.app_settings.lock().await.cloud_provider.clone(); + let mut integrations = state.integrations.lock().await; + integrations.stop_cloud(); + if matches!(provider, CloudProvider::Local) { + integrations.stop_cloud_listener(); + } +} + +#[cfg(not(mobile))] +pub(crate) async fn apply_settings(app: AppHandle) { + let app_for_task = app.clone(); + let state = app.state::(); + let settings = state.app_settings.lock().await.clone(); + + let mut integrations = state.integrations.lock().await; + + let provider = settings.cloud_provider.clone(); + let config = match provider { + CloudProvider::Nats => settings.nats_url.clone(), + CloudProvider::Cloudkit => settings.cloudkit_container_id.clone(), + CloudProvider::Local => None, + }; + + let needs_cloud = !matches!(provider, CloudProvider::Local); + let should_restart = match integrations.cloud.as_ref() { + None => needs_cloud, + Some(current) => { + if !needs_cloud { + true + } else { + current.provider != provider || current.config != config + } + } + }; + + if should_restart { + integrations.stop_cloud(); + } + + if !needs_cloud { + return; + } + + if integrations.cloud.is_some() { + return; + } + + let (event_tx, event_rx) = mpsc::unbounded_channel::(); + let runner_id = settings.runner_id.clone(); + + let handle = match provider { + CloudProvider::Nats => { + let nats_url = settings.nats_url.clone().unwrap_or_default(); + tokio::spawn(async move { + nats::run_nats_cloud(app_for_task, runner_id, nats_url, event_rx).await; + }) + } + CloudProvider::Cloudkit => { + tokio::spawn(async move { + // Placeholder: CloudKit transport is wired in later. + let _ = event_rx; + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + } + }) + } + CloudProvider::Local => unreachable!(), + }; + + integrations.cloud = Some(CloudRuntime { + provider, + config, + event_tx, + handle, + }); +} + +pub(crate) fn try_emit_app_server_event(app: &AppHandle, event: AppServerEvent) { + let Some(state) = app.try_state::() else { + return; + }; + let integrations = state.integrations.try_lock(); + let Some(integrations) = integrations.ok() else { + return; + }; + let Some(cloud) = integrations.cloud.as_ref() else { + return; + }; + let _ = cloud.event_tx.send(event); +} + +async fn ensure_connected(app: &AppHandle, workspace_id: &str) -> Result<(), String> { + let state = app.state::(); + if state.sessions.lock().await.contains_key(workspace_id) { + return Ok(()); + } + crate::workspaces::connect_workspace(workspace_id.to_string(), state, app.clone()).await +} + +async fn handle_rpc_inner(app: &AppHandle, req: &RpcRequest) -> Result { + let method = req.method.as_str(); + match method { + "ping" => Ok(json!({"ok": true})), + "list_workspaces" => { + let state = app.state::(); + let workspaces = crate::workspaces::list_workspaces(state).await?; + serde_json::to_value(workspaces).map_err(|e| e.to_string()) + } + "connect_workspace" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .or_else(|| req.params.get("id").and_then(|v| v.as_str())) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + Ok(json!({"ok": true})) + } + "list_threads" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + let cursor = req.params.get("cursor").cloned().unwrap_or(Value::Null); + let limit = req.params.get("limit").cloned().unwrap_or(Value::Null); + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request( + "thread/list", + json!({ "cursor": cursor, "limit": limit }), + ) + .await + } + "resume_thread" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let thread_id = req + .params + .get("threadId") + .and_then(|v| v.as_str()) + .ok_or("missing threadId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request("thread/resume", json!({ "threadId": thread_id })) + .await + } + "archive_thread" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let thread_id = req + .params + .get("threadId") + .and_then(|v| v.as_str()) + .ok_or("missing threadId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request("thread/archive", json!({ "threadId": thread_id })) + .await + } + "start_thread" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request( + "thread/start", + json!({ "cwd": session.entry.path, "approvalPolicy": "on-request" }), + ) + .await + } + "send_user_message" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let thread_id = req + .params + .get("threadId") + .and_then(|v| v.as_str()) + .ok_or("missing threadId")?; + let text = req + .params + .get("text") + .and_then(|v| v.as_str()) + .ok_or("missing text")?; + ensure_connected(app, workspace_id).await?; + + let model = req.params.get("model").cloned().unwrap_or(Value::Null); + let effort = req.params.get("effort").cloned().unwrap_or(Value::Null); + let access_mode = req + .params + .get("accessMode") + .and_then(|v| v.as_str()) + .unwrap_or("current") + .to_string(); + let images = req.params.get("images").cloned().unwrap_or(Value::Null); + + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + + let sandbox_policy = match access_mode.as_str() { + "full-access" => json!({ "type": "dangerFullAccess" }), + "read-only" => json!({ "type": "readOnly" }), + _ => json!({ + "type": "workspaceWrite", + "writableRoots": [session.entry.path], + "networkAccess": true + }), + }; + let approval_policy = if access_mode == "full-access" { + "never" + } else { + "on-request" + }; + + let mut input: Vec = Vec::new(); + if !text.trim().is_empty() { + input.push(json!({ "type": "text", "text": text.trim() })); + } + if let Value::Array(items) = images { + for item in items { + let Some(path) = item.as_str() else { + continue; + }; + let trimmed = path.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with("data:") + || trimmed.starts_with("http://") + || trimmed.starts_with("https://") + { + input.push(json!({ "type": "image", "url": trimmed })); + } else { + input.push(json!({ "type": "localImage", "path": trimmed })); + } + } + } + if input.is_empty() { + return Err("empty user message".to_string()); + } + + session + .send_request( + "turn/start", + json!({ + "threadId": thread_id, + "input": input, + "cwd": session.entry.path, + "approvalPolicy": approval_policy, + "sandboxPolicy": sandbox_policy, + "model": model, + "effort": effort, + }), + ) + .await + } + "turn_interrupt" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let thread_id = req + .params + .get("threadId") + .and_then(|v| v.as_str()) + .ok_or("missing threadId")?; + let turn_id = req + .params + .get("turnId") + .and_then(|v| v.as_str()) + .ok_or("missing turnId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request( + "turn/interrupt", + json!({ "threadId": thread_id, "turnId": turn_id }), + ) + .await + } + "start_review" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let thread_id = req + .params + .get("threadId") + .and_then(|v| v.as_str()) + .ok_or("missing threadId")?; + let target = req + .params + .get("target") + .cloned() + .ok_or("missing `target`")?; + let delivery = req.params.get("delivery").and_then(|v| v.as_str()); + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + let mut payload = json!({ "threadId": thread_id, "target": target }); + if let Some(delivery) = delivery { + if let Some(obj) = payload.as_object_mut() { + obj.insert("delivery".to_string(), json!(delivery)); + } + } + session.send_request("review/start", payload).await + } + "model_list" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session.send_request("model/list", json!({})).await + } + "account_rate_limits" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request("account/rateLimits/read", Value::Null) + .await + } + "skills_list" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session + .send_request("skills/list", json!({ "cwd": session.entry.path })) + .await + } + "prompts_list" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let prompts = crate::prompts::prompts_list(workspace_id).await?; + serde_json::to_value(prompts).map_err(|e| e.to_string()) + } + "list_workspace_files" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let state = app.state::(); + let workspaces = state.workspaces.lock().await; + let entry = workspaces + .get(workspace_id) + .ok_or("workspace not found")?; + let root = std::path::PathBuf::from(&entry.path); + let files = crate::workspaces::list_workspace_files_inner(&root, usize::MAX); + serde_json::to_value(files).map_err(|e| e.to_string()) + } + "respond_to_server_request" => { + let workspace_id = req + .params + .get("workspaceId") + .and_then(|v| v.as_str()) + .ok_or("missing workspaceId")?; + let request_id = req + .params + .get("requestId") + .and_then(|v| v.as_u64()) + .ok_or("missing requestId")?; + let result = req.params.get("result").cloned().unwrap_or(Value::Null); + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + session.send_response(request_id, result).await?; + Ok(json!({ "ok": true })) + } + _ => Err(format!("unknown method: {method}")), + } +} + +pub(crate) async fn handle_nats_command(app: &AppHandle, payload: &str) -> Option { + let req: RpcRequest = serde_json::from_str(payload).ok()?; + let id = req.id.clone(); + let res = match handle_rpc_inner(app, &req).await { + Ok(result) => RpcResponse::ok(id, result), + Err(err) => RpcResponse::err(id, err), + }; + serde_json::to_string(&res).ok() +} + +#[tauri::command] +pub(crate) async fn nats_status(app: AppHandle) -> Result { + let state = app.state::(); + let settings = state.app_settings.lock().await; + let url = settings.nats_url.clone().unwrap_or_default(); + nats::nats_status(url).await +} + +#[tauri::command] +pub(crate) async fn cloudkit_status() -> Result { + Ok(CloudKitStatus { + available: false, + status: "CloudKit not wired yet.".to_string(), + }) +} + +#[tauri::command] +pub(crate) async fn cloudkit_test() -> Result { + Err("CloudKit not wired yet.".to_string()) +} + +#[tauri::command] +pub(crate) async fn cloud_discover_runner(app: AppHandle) -> Result, String> { + let state = app.state::(); + let settings = state.app_settings.lock().await.clone(); + match settings.cloud_provider { + CloudProvider::Nats => { + let url = settings + .nats_url + .ok_or("NATS URL not configured.".to_string())?; + nats::nats_discover_runner(&url, 7000).await + } + _ => Ok(None), + } +} + +#[tauri::command] +pub(crate) async fn cloud_rpc( + runner_id: String, + method: String, + params: Value, + app: AppHandle, +) -> Result { + let state = app.state::(); + let settings = state.app_settings.lock().await.clone(); + match settings.cloud_provider { + CloudProvider::Local => { + let req = RpcRequest { + id: json!("local"), + method, + params, + }; + handle_rpc_inner(&app, &req).await + } + CloudProvider::Nats => { + let url = settings + .nats_url + .ok_or("NATS URL not configured.".to_string())?; + let id = json!(uuid::Uuid::new_v4().to_string()); + let req_json = serde_json::to_string(&RpcRequest { id: id.clone(), method, params }) + .map_err(|e| e.to_string())?; + let reply_json = nats::nats_request( + &url, + format!("cm.cmd.{runner_id}"), + req_json, + 15_000, + ) + .await?; + let response: RpcResponse = + serde_json::from_str(&reply_json).map_err(|e| e.to_string())?; + if response.error.is_some() { + let message = response + .error + .map(|e| e.message) + .unwrap_or_else(|| "Unknown error".to_string()); + return Err(message); + } + Ok(response.result.unwrap_or(Value::Null)) + } + CloudProvider::Cloudkit => Err("CloudKit not wired yet.".to_string()), + } +} + +#[tauri::command] +pub(crate) async fn cloud_subscribe_runner_events( + runner_id: String, + app: AppHandle, +) -> Result<(), String> { + let state = app.state::(); + let settings = state.app_settings.lock().await.clone(); + let provider = settings.cloud_provider.clone(); + let config = match provider { + CloudProvider::Nats => settings.nats_url.clone(), + CloudProvider::Cloudkit => settings.cloudkit_container_id.clone(), + CloudProvider::Local => None, + }; + + if matches!(provider, CloudProvider::Local) { + let mut integrations = state.integrations.lock().await; + integrations.stop_cloud_listener(); + return Ok(()); + } + + let needs_restart = { + let integrations = state.integrations.lock().await; + match integrations.cloud_listener.as_ref() { + None => true, + Some(current) => { + current.provider != provider + || current.runner_id != runner_id + || current.config != config + } + } + }; + + if !needs_restart { + return Ok(()); + } + + let mut integrations = state.integrations.lock().await; + integrations.stop_cloud_listener(); + + match provider { + CloudProvider::Nats => { + let url = settings + .nats_url + .ok_or("NATS URL not configured.".to_string())?; + let app_for_task = app.clone(); + let runner_id_for_task = runner_id.clone(); + let handle = tokio::spawn(async move { + nats::run_nats_event_listener(app_for_task, runner_id_for_task, url).await; + }); + integrations.cloud_listener = Some(CloudListenerRuntime { + provider, + runner_id, + config, + handle, + }); + Ok(()) + } + CloudProvider::Cloudkit => Err("CloudKit not wired yet.".to_string()), + CloudProvider::Local => Ok(()), + } +} diff --git a/src-tauri/src/integrations/nats.rs b/src-tauri/src/integrations/nats.rs new file mode 100644 index 000000000..c54c5e685 --- /dev/null +++ b/src-tauri/src/integrations/nats.rs @@ -0,0 +1,232 @@ +use std::time::Duration; + +use async_nats::{Client, ConnectOptions}; +use futures_util::StreamExt; +use serde_json::json; +use tauri::{AppHandle, Emitter}; +use tokio::sync::mpsc; + +use crate::backend::events::AppServerEvent; +use crate::integrations::{handle_nats_command, NatsStatus}; + +fn parse_nats_auth(url: &str) -> (String, Option) { + // Accept `nats://token@host:4222` or `nats://user:pass@host:4222`. + // We treat single "user" (no ':') as token for convenience. + let Ok(parsed) = url::Url::parse(url) else { + return (url.to_string(), None); + }; + + let username = parsed.username(); + let password = parsed.password(); + if username.is_empty() { + return (url.to_string(), None); + } + + let has_password = password.unwrap_or("").is_empty() == false; + if has_password { + // async-nats supports user/pass in URL directly, no special casing needed. + return (url.to_string(), None); + } + + // token + let mut without_auth = parsed.clone(); + let _ = without_auth.set_username(""); + let _ = without_auth.set_password(None); + (without_auth.to_string(), Some(username.to_string())) +} + +async fn connect(url: &str) -> Result { + let (url, token) = parse_nats_auth(url); + let mut opts = ConnectOptions::new(); + if let Some(token) = token { + opts = opts.token(token); + } + opts.connect(url) + .await + .map_err(|error| format!("Failed to connect to NATS: {error}")) +} + +pub(crate) async fn nats_status(url: String) -> Result { + if url.trim().is_empty() { + return Ok(NatsStatus { + ok: false, + server: None, + error: Some("NATS URL is empty.".to_string()), + }); + } + let client = connect(&url).await?; + let info = client.server_info(); + Ok(NatsStatus { + ok: true, + server: Some(format!("{}:{}", info.host, info.port)), + error: None, + }) +} + +pub(crate) async fn run_nats_cloud( + app: AppHandle, + runner_id: String, + url: String, + mut events: mpsc::UnboundedReceiver, +) { + let cmd_subject = format!("cm.cmd.{runner_id}"); + let res_subject = format!("cm.res.{runner_id}"); + + let mut presence_interval = tokio::time::interval(Duration::from_secs(5)); + presence_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + let client = match connect(&url).await { + Ok(client) => client, + Err(err) => { + eprintln!("[nats] {err}"); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + let mut sub = match client.subscribe(cmd_subject.clone()).await { + Ok(sub) => sub, + Err(err) => { + eprintln!("[nats] Failed to subscribe to commands: {err}"); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + // Emit presence immediately. + let _ = client + .publish( + format!("cm.presence.{runner_id}"), + json!({ "runnerId": runner_id, "ok": true }) + .to_string() + .into(), + ) + .await; + + loop { + tokio::select! { + _ = presence_interval.tick() => { + if client.publish( + format!("cm.presence.{runner_id}"), + json!({ "runnerId": runner_id, "ok": true }) + .to_string() + .into(), + ).await.is_err() { + break; + } + } + msg = sub.next() => { + let Some(msg) = msg else { + break; + }; + let payload = String::from_utf8_lossy(&msg.payload).to_string(); + if let Some(response_json) = handle_nats_command(&app, &payload).await { + if let Some(reply) = msg.reply { + let _ = client.publish(reply, response_json.into()).await; + } else { + let _ = client.publish(res_subject.clone(), response_json.into()).await; + } + } + } + event = events.recv() => { + let Some(event) = event else { + return; + }; + let subject = format!("cm.ev.{runner_id}.{}", event.workspace_id); + let payload = serde_json::to_string(&event).unwrap_or_default(); + if client.publish(subject, payload.into()).await.is_err() { + break; + } + } + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +pub(crate) async fn nats_request( + url: &str, + subject: String, + payload: String, + timeout_ms: u64, +) -> Result { + let client = connect(url).await?; + let fut = client.request(subject, payload.into()); + let msg = tokio::time::timeout(Duration::from_millis(timeout_ms), fut) + .await + .map_err(|_| "Timed out waiting for NATS reply.".to_string())? + .map_err(|e| format!("NATS request failed: {e}"))?; + Ok(String::from_utf8_lossy(&msg.payload).to_string()) +} + +pub(crate) async fn nats_discover_runner(url: &str, timeout_ms: u64) -> Result, String> { + let client = connect(url).await?; + let mut sub = client + .subscribe("cm.presence.*".to_string()) + .await + .map_err(|e| format!("Failed to subscribe to presence: {e}"))?; + let deadline = tokio::time::sleep(Duration::from_millis(timeout_ms)); + tokio::pin!(deadline); + let mut last: Option = None; + loop { + tokio::select! { + _ = &mut deadline => { + return Ok(last); + } + msg = sub.next() => { + let Some(msg) = msg else { + return Ok(last); + }; + let payload = String::from_utf8_lossy(&msg.payload).to_string(); + if let Ok(value) = serde_json::from_str::(&payload) { + if let Some(runner_id) = value.get("runnerId").and_then(|v| v.as_str()) { + last = Some(runner_id.to_string()); + } + } + } + } + } +} + +pub(crate) async fn run_nats_event_listener(app: AppHandle, runner_id: String, url: String) { + let subject = format!("cm.ev.{runner_id}.*"); + loop { + let client = match connect(&url).await { + Ok(client) => client, + Err(err) => { + eprintln!("[nats-client] {err}"); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + let mut sub = match client.subscribe(subject.clone()).await { + Ok(sub) => sub, + Err(err) => { + eprintln!("[nats-client] Failed to subscribe to events: {err}"); + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + }; + + loop { + let msg = sub.next().await; + let Some(msg) = msg else { + break; + }; + let payload = String::from_utf8_lossy(&msg.payload).to_string(); + match serde_json::from_str::(&payload) { + Ok(event) => { + let _ = app.emit("app-server-event", event); + } + Err(err) => { + eprintln!("[nats-client] Failed to parse AppServerEvent: {err}"); + } + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc3b23dda..e872156cb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,24 +1,42 @@ +use tauri::Manager; + +#[cfg(desktop)] use tauri::menu::{Menu, MenuItemBuilder, PredefinedMenuItem, Submenu}; -use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +#[cfg(desktop)] +use tauri::{WebviewUrl, WebviewWindowBuilder}; mod backend; mod codex; mod codex_home; mod codex_config; -#[cfg(not(target_os = "windows"))] +#[cfg(all(desktop, not(target_os = "windows")))] #[path = "dictation.rs"] mod dictation; -#[cfg(target_os = "windows")] +#[cfg(all(desktop, target_os = "windows"))] #[path = "dictation_stub.rs"] mod dictation; +#[cfg(mobile)] +#[path = "dictation_mobile.rs"] +mod dictation; mod event_sink; +#[cfg(desktop)] mod git; +#[cfg(desktop)] mod git_utils; +mod integrations; +#[cfg(mobile)] +#[path = "git_mobile.rs"] +mod git; +#[cfg(desktop)] mod local_usage; mod prompts; mod rules; mod settings; mod state; +#[cfg(desktop)] +mod terminal; +#[cfg(mobile)] +#[path = "terminal_mobile.rs"] mod terminal; mod window; mod storage; @@ -36,7 +54,10 @@ pub fn run() { } } - tauri::Builder::default() + let builder = tauri::Builder::default(); + + #[cfg(desktop)] + let builder = builder .enable_macos_default_menu(false) .menu(|handle| { let app_name = handle.package_info().name.clone(); @@ -212,10 +233,16 @@ pub fn run() { } _ => {} } - }) + }); + + #[cfg(mobile)] + let builder = builder; + + builder .setup(|app| { let state = state::AppState::load(&app.handle()); app.manage(state); + tauri::async_runtime::spawn(integrations::apply_settings(app.handle().clone())); #[cfg(desktop)] app.handle() .plugin(tauri_plugin_updater::Builder::new().build())?; @@ -287,7 +314,14 @@ pub fn run() { dictation::dictation_start, dictation::dictation_stop, dictation::dictation_cancel, - local_usage::local_usage_snapshot + integrations::nats_status, + integrations::cloudkit_status, + integrations::cloudkit_test, + integrations::cloud_discover_runner, + integrations::cloud_rpc, + integrations::cloud_subscribe_runner_events, + #[cfg(desktop)] + local_usage::local_usage_snapshot, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 9bce0693f..22c4e9acf 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,6 +1,7 @@ -use tauri::{State, Window}; +use tauri::{AppHandle, State, Window}; use crate::codex_config; +use crate::integrations; use crate::state::AppState; use crate::storage::write_settings; use crate::types::AppSettings; @@ -30,6 +31,7 @@ pub(crate) async fn update_app_settings( settings: AppSettings, state: State<'_, AppState>, window: Window, + app: AppHandle, ) -> Result { let _ = codex_config::write_collab_enabled(settings.experimental_collab_enabled); let _ = codex_config::write_steer_enabled(settings.experimental_steer_enabled); @@ -38,5 +40,6 @@ pub(crate) async fn update_app_settings( let mut current = state.app_settings.lock().await; *current = settings.clone(); let _ = window::apply_window_appearance(&window, settings.theme.as_str()); + tauri::async_runtime::spawn(integrations::apply_settings(app)); Ok(settings) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5465e697f..aa1a78c1e 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -4,9 +4,11 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; +use uuid::Uuid; use crate::dictation::DictationState; -use crate::storage::{read_settings, read_workspaces}; +use crate::storage::{read_settings, read_workspaces, write_settings}; +use crate::integrations::IntegrationsRuntime; use crate::types::{AppSettings, WorkspaceEntry}; pub(crate) struct AppState { @@ -18,6 +20,7 @@ pub(crate) struct AppState { pub(crate) settings_path: PathBuf, pub(crate) app_settings: Mutex, pub(crate) dictation: Mutex, + pub(crate) integrations: Mutex, } impl AppState { @@ -29,7 +32,36 @@ 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(); + let defaults = AppSettings::default(); + let mut settings_changed = false; + if app_settings.runner_id.trim().is_empty() || app_settings.runner_id == "unknown" { + app_settings.runner_id = Uuid::new_v4().to_string(); + settings_changed = true; + } + if app_settings + .nats_url + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + app_settings.nats_url = defaults.nats_url; + settings_changed = true; + } + if app_settings + .cloudkit_container_id + .as_deref() + .unwrap_or("") + .trim() + .is_empty() + { + app_settings.cloudkit_container_id = defaults.cloudkit_container_id; + settings_changed = true; + } + if settings_changed { + let _ = write_settings(&settings_path, &app_settings); + } Self { workspaces: Mutex::new(workspaces), sessions: Mutex::new(HashMap::new()), @@ -38,6 +70,7 @@ impl AppState { settings_path, app_settings: Mutex::new(app_settings), dictation: Mutex::new(DictationState::default()), + integrations: Mutex::new(IntegrationsRuntime::default()), } } } diff --git a/src-tauri/src/terminal_mobile.rs b/src-tauri/src/terminal_mobile.rs new file mode 100644 index 000000000..7ee3adf86 --- /dev/null +++ b/src-tauri/src/terminal_mobile.rs @@ -0,0 +1,63 @@ +use serde::Serialize; +use tauri::{AppHandle, State}; + +use crate::state::AppState; + +fn unsupported() -> String { + "Terminal is not supported on mobile builds.".to_string() +} + +pub(crate) struct TerminalSession { + pub(crate) id: String, +} + +#[derive(Debug, Serialize, Clone)] +pub(crate) struct TerminalSessionInfo { + id: 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 { + if terminal_id.trim().is_empty() { + return Err("Terminal id is required".to_string()); + } + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn terminal_write( + _workspace_id: String, + _terminal_id: String, + _data: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn terminal_resize( + _workspace_id: String, + _terminal_id: String, + _cols: u16, + _rows: u16, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn terminal_close( + _workspace_id: String, + _terminal_id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 0f391c499..4c8445911 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -229,6 +229,33 @@ pub(crate) struct WorkspaceSettings { pub(crate) git_root: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "lowercase")] +pub(crate) enum BackendMode { + Local, + Remote, +} + +impl Default for BackendMode { + fn default() -> Self { + BackendMode::Local + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CloudProvider { + Local, + Nats, + Cloudkit, +} + +impl Default for CloudProvider { + fn default() -> Self { + CloudProvider::Local + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] @@ -300,19 +327,24 @@ pub(crate) struct AppSettings { pub(crate) dictation_hold_key: String, #[serde(default = "default_workspace_groups", rename = "workspaceGroups")] pub(crate) workspace_groups: Vec, -} -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "lowercase")] -pub(crate) enum BackendMode { - Local, - Remote, -} - -impl Default for BackendMode { - fn default() -> Self { - BackendMode::Local - } + #[serde(default = "default_runner_id", rename = "runnerId")] + pub(crate) runner_id: String, + #[serde(default, rename = "cloudProvider")] + pub(crate) cloud_provider: CloudProvider, + #[serde(default = "default_nats_url", rename = "natsUrl")] + pub(crate) nats_url: Option, + #[serde(default = "default_cloudkit_container_id", rename = "cloudKitContainerId")] + pub(crate) cloudkit_container_id: 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: Option>, + #[serde(default, rename = "telegramDefaultChatId")] + pub(crate) telegram_default_chat_id: Option, } fn default_access_mode() -> String { @@ -375,6 +407,19 @@ fn default_workspace_groups() -> Vec { Vec::new() } +fn default_runner_id() -> String { + // Filled on first load by state::AppState to ensure stability. + "unknown".to_string() +} + +fn default_nats_url() -> Option { + None +} + +fn default_cloudkit_container_id() -> Option { + None +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -399,6 +444,14 @@ impl Default for AppSettings { dictation_preferred_language: None, dictation_hold_key: default_dictation_hold_key(), workspace_groups: default_workspace_groups(), + runner_id: default_runner_id(), + cloud_provider: CloudProvider::default(), + nats_url: default_nats_url(), + cloudkit_container_id: default_cloudkit_container_id(), + telegram_enabled: false, + telegram_bot_token: None, + telegram_allowed_user_ids: None, + telegram_default_chat_id: None, } } } @@ -406,7 +459,8 @@ impl Default for AppSettings { #[cfg(test)] mod tests { use super::{ - AppSettings, BackendMode, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, WorkspaceSettings, + AppSettings, BackendMode, CloudProvider, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, + WorkspaceSettings, }; #[test] @@ -439,6 +493,11 @@ mod tests { assert_eq!(settings.dictation_model_id, "base"); assert!(settings.dictation_preferred_language.is_none()); assert_eq!(settings.dictation_hold_key, "alt"); + assert_eq!(settings.runner_id, "unknown"); + assert!(matches!(settings.cloud_provider, CloudProvider::Local)); + assert!(settings.nats_url.is_none()); + assert!(settings.cloudkit_container_id.is_none()); + assert!(!settings.telegram_enabled); assert!(settings.workspace_groups.is_empty()); } diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 6d3910eb7..97d85f046 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -59,7 +59,7 @@ fn sanitize_clone_dir_name(name: &str) -> String { } } -fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { +pub(crate) fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { let mut results = Vec::new(); let walker = WalkBuilder::new(root) // Allow hidden entries. diff --git a/src/App.tsx b/src/App.tsx index e3e8c7421..d3d6cc712 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import "./styles/settings.css"; import "./styles/compact-base.css"; import "./styles/compact-phone.css"; import "./styles/compact-tablet.css"; +import "./styles/cloud-client.css"; import successSoundUrl from "./assets/success-notification.mp3"; import errorSoundUrl from "./assets/error-notification.mp3"; import { WorktreePrompt } from "./features/workspaces/components/WorktreePrompt"; @@ -31,6 +32,7 @@ import { ClonePrompt } from "./features/workspaces/components/ClonePrompt"; import { RenameThreadPrompt } from "./features/threads/components/RenameThreadPrompt"; import { AboutView } from "./features/about/components/AboutView"; import { SettingsView } from "./features/settings/components/SettingsView"; +import { CloudClientApp } from "./features/cloudClient/components/CloudClientApp"; import { DesktopLayout } from "./features/layout/components/DesktopLayout"; import { TabletLayout } from "./features/layout/components/TabletLayout"; import { PhoneLayout } from "./features/layout/components/PhoneLayout"; @@ -89,10 +91,15 @@ 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 { playNotificationSound } from "./utils/notificationSounds"; import { + cloudDiscoverRunner, + cloudkitStatus, + cloudkitTest, + natsStatus, pickWorkspacePath, } from "./services/tauri"; +import { playNotificationSound } from "./utils/notificationSounds"; +import { isAppleMobileDevice } from "./utils/platform"; import type { AccessMode, GitHubPullRequest, @@ -109,6 +116,7 @@ function MainApp() { isLoading: appSettingsLoading } = useAppSettings(); useThemePreference(appSettings.theme); + const isMobile = isAppleMobileDevice(); const dictationModel = useDictationModel(appSettings.dictationModelId); const { state: dictationState, @@ -193,6 +201,7 @@ function MainApp() { ); type SettingsSection = | "projects" + | "cloud" | "display" | "dictation" | "shortcuts" @@ -266,7 +275,7 @@ function MainApp() { const composerInputRef = useRef(null); - const updater = useUpdater({ onDebug: addDebugEntry }); + const updater = useUpdater({ enabled: !isMobile, onDebug: addDebugEntry }); const isWindowFocused = useWindowFocusState(); const nextTestSoundIsError = useRef(false); @@ -1214,13 +1223,244 @@ function MainApp() { onDebug: addDebugEntry, }); const isDefaultScale = Math.abs(uiScale - 1) < 0.001; + const isRemoteClient = isMobile && (appSettings.cloudProvider ?? "local") !== "local"; + const [remoteRunnerId, setRemoteRunnerId] = useState(null); + const isE2E = import.meta.env.VITE_E2E === "1" && isMobile && isRemoteClient; + const [e2eState, setE2eState] = useState<{ + status: "idle" | "running" | "pass" | "fail"; + step: string; + error: string | null; + }>({ status: "idle", step: "idle", error: null }); + + const remoteRunnerIdRef = useRef(null); + const workspacesRef = useRef(workspaces); + const activeWorkspaceIdRef = useRef(activeWorkspaceId); + const activeThreadIdRef = useRef(activeThreadId); + const threadsByWorkspaceRef = useRef(threadsByWorkspace); + const threadStatusByIdRef = useRef(threadStatusById); + const lastAgentMessageByThreadRef = useRef(lastAgentMessageByThread); + const refreshWorkspacesRef = useRef(refreshWorkspaces); + const connectWorkspaceRef = useRef(connectWorkspace); + const selectWorkspaceRef = useRef(selectWorkspace); + const startThreadForWorkspaceRef = useRef(startThreadForWorkspace); + const listThreadsForWorkspaceRef = useRef(listThreadsForWorkspace); + const setActiveThreadIdRef = useRef(setActiveThreadId); + const sendUserMessageRef = useRef(sendUserMessage); + + useEffect(() => { + remoteRunnerIdRef.current = remoteRunnerId; + }, [remoteRunnerId]); + useEffect(() => { + workspacesRef.current = workspaces; + }, [workspaces]); + useEffect(() => { + activeWorkspaceIdRef.current = activeWorkspaceId; + }, [activeWorkspaceId]); + useEffect(() => { + activeThreadIdRef.current = activeThreadId; + }, [activeThreadId]); + useEffect(() => { + threadsByWorkspaceRef.current = threadsByWorkspace; + }, [threadsByWorkspace]); + useEffect(() => { + threadStatusByIdRef.current = threadStatusById; + }, [threadStatusById]); + useEffect(() => { + lastAgentMessageByThreadRef.current = lastAgentMessageByThread; + }, [lastAgentMessageByThread]); + useEffect(() => { + refreshWorkspacesRef.current = refreshWorkspaces; + }, [refreshWorkspaces]); + useEffect(() => { + connectWorkspaceRef.current = connectWorkspace; + }, [connectWorkspace]); + useEffect(() => { + selectWorkspaceRef.current = selectWorkspace; + }, [selectWorkspace]); + useEffect(() => { + startThreadForWorkspaceRef.current = startThreadForWorkspace; + }, [startThreadForWorkspace]); + useEffect(() => { + listThreadsForWorkspaceRef.current = listThreadsForWorkspace; + }, [listThreadsForWorkspace]); + useEffect(() => { + setActiveThreadIdRef.current = setActiveThreadId; + }, [setActiveThreadId]); + useEffect(() => { + sendUserMessageRef.current = sendUserMessage; + }, [sendUserMessage]); + + useEffect(() => { + if (!isRemoteClient) { + setRemoteRunnerId(null); + return; + } + let active = true; + const tick = async () => { + try { + const runnerId = await cloudDiscoverRunner(); + if (active) { + setRemoteRunnerId(runnerId); + } + } catch { + if (active) { + setRemoteRunnerId(null); + } + } + }; + void tick(); + const interval = window.setInterval(() => void tick(), 8000); + return () => { + active = false; + window.clearInterval(interval); + }; + }, [isRemoteClient, appSettings.cloudProvider, appSettings.natsUrl, appSettings.cloudKitContainerId]); + + useEffect(() => { + if (isRemoteClient && remoteRunnerId) { + void refreshWorkspaces(); + } + }, [isRemoteClient, remoteRunnerId, refreshWorkspaces]); + + useEffect(() => { + if (!isE2E || e2eState.status !== "idle") { + return; + } + let canceled = false; + const sleep = (ms: number) => + new Promise((resolve) => window.setTimeout(resolve, ms)); + const waitFor = async (predicate: () => boolean, timeoutMs: number) => { + const start = Date.now(); + while (!canceled) { + if (predicate()) { + return; + } + if (Date.now() - start > timeoutMs) { + throw new Error("Timed out waiting for UI state."); + } + await sleep(250); + } + }; + + void (async () => { + try { + setE2eState({ + status: "running", + step: "Discovering macOS runner…", + error: null, + }); + const runnerDeadline = Date.now() + 60_000; + while (!canceled && !remoteRunnerIdRef.current) { + const discovered = await cloudDiscoverRunner().catch(() => null); + if (discovered) { + remoteRunnerIdRef.current = discovered; + setRemoteRunnerId(discovered); + } + if (remoteRunnerIdRef.current) { + break; + } + if (Date.now() > runnerDeadline) { + throw new Error( + "No macOS runner discovered. Start CodexMonitor on macOS with Cloud provider set to NATS.", + ); + } + await sleep(1000); + } + + setE2eState({ status: "running", step: "Loading projects…", error: null }); + const workspacesDeadline = Date.now() + 60_000; + while (!canceled && workspacesRef.current.length === 0) { + await refreshWorkspacesRef.current().catch(() => []); + if (workspacesRef.current.length > 0) { + break; + } + if (Date.now() > workspacesDeadline) { + throw new Error("No projects found on the macOS runner."); + } + await sleep(1000); + } + + const workspace = workspacesRef.current[0]; + if (!workspace) { + throw new Error("No projects found on the macOS runner."); + } + + setE2eState({ + status: "running", + step: `Selecting project: ${workspace.name}…`, + error: null, + }); + selectWorkspaceRef.current(workspace.id); + await waitFor(() => activeWorkspaceIdRef.current === workspace.id, 10_000); + + setE2eState({ status: "running", step: "Connecting…", error: null }); + await connectWorkspaceRef.current(workspace).catch(() => undefined); + + setE2eState({ status: "running", step: "Loading agents…", error: null }); + await listThreadsForWorkspaceRef.current(workspace).catch(() => undefined); + + const existingThreads = threadsByWorkspaceRef.current[workspace.id] ?? []; + let threadId: string | null = existingThreads.length ? existingThreads[0].id : null; + + if (threadId) { + setE2eState({ status: "running", step: "Opening first agent…", error: null }); + setActiveThreadIdRef.current(threadId, workspace.id); + } else { + setE2eState({ status: "running", step: "Starting agent…", error: null }); + threadId = await startThreadForWorkspaceRef.current(workspace.id); + if (!threadId) { + throw new Error("Failed to start agent thread."); + } + } + await waitFor(() => activeThreadIdRef.current === threadId, 10_000); + + setE2eState({ status: "running", step: "Sending message…", error: null }); + const sentAt = Date.now(); + await sendUserMessageRef.current("Erzähl einen Witz.", []); + + setE2eState({ status: "running", step: "Waiting for reply…", error: null }); + const replyDeadline = Date.now() + 120_000; + while (!canceled) { + const last = threadId ? lastAgentMessageByThreadRef.current[threadId] : null; + if (last?.text?.trim() && last.timestamp > sentAt) { + break; + } + if (Date.now() > replyDeadline) { + throw new Error("Timed out waiting for agent reply."); + } + if (threadId) { + setActiveThreadIdRef.current(threadId, workspace.id); + } + await sleep(3000); + } + + setE2eState({ status: "pass", step: "PASS", error: null }); + } catch (error) { + if (canceled) { + return; + } + setE2eState({ + status: "fail", + step: "FAIL", + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + + return () => { + canceled = true; + }; + }, [e2eState.status, isE2E]); const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ isPhone ? " layout-phone" : "" }${isTablet ? " layout-tablet" : ""}${ reduceTransparency ? " reduced-transparency" : "" }${!isCompact && sidebarCollapsed ? " sidebar-collapsed" : ""}${ !isCompact && rightPanelCollapsed ? " right-panel-collapsed" : "" - }${isDefaultScale ? " ui-scale-default" : ""}`; + }${isDefaultScale ? " ui-scale-default" : ""}${ + isMobile ? " force-dark" : "" + }`; + const showRemoteRunnerNotice = isRemoteClient && !remoteRunnerId; const { sidebarNode, messagesNode, @@ -1264,6 +1504,14 @@ function MainApp() { onOpenDebug: handleDebugClick, showDebugButton, onAddWorkspace: handleAddWorkspace, + onHomeOpenProject: isRemoteClient ? () => handleOpenSettings("cloud") : undefined, + onHomeAddWorkspace: isRemoteClient ? () => void refreshWorkspaces() : undefined, + homeOpenProjectLabel: isRemoteClient ? "Cloud Settings" : undefined, + homeAddWorkspaceLabel: isRemoteClient ? "Refresh Projects" : undefined, + homeNoticeTitle: showRemoteRunnerNotice ? "macOS runner required" : null, + homeNoticeSubtitle: showRemoteRunnerNotice + ? "Start CodexMonitor on macOS (runner). Projects will appear here automatically once discovered." + : null, onSelectHome: selectHome, onSelectWorkspace: (workspaceId) => { exitDiffView(); @@ -1565,6 +1813,13 @@ function MainApp() { } >
+ {isE2E ? ( +
+
E2E Joke Test
+
{e2eState.step}
+ {e2eState.error ?
{e2eState.error}
: null} +
+ ) : null} {isPhone ? ( { await updateWorkspaceCodexBin(id, codexBin); }} @@ -1709,6 +1967,13 @@ function App() { if (windowLabel === "about") { return ; } + const cloudClientEnabled = + isAppleMobileDevice() && + typeof window !== "undefined" && + new URLSearchParams(window.location.search).get("cloudClient") === "1"; + if (cloudClientEnabled) { + return ; + } return ; } diff --git a/src/features/cloudClient/components/CloudClientApp.tsx b/src/features/cloudClient/components/CloudClientApp.tsx new file mode 100644 index 000000000..011f21ef9 --- /dev/null +++ b/src/features/cloudClient/components/CloudClientApp.tsx @@ -0,0 +1,345 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAppSettings } from "../../settings/hooks/useAppSettings"; +import { useCloudClient } from "../hooks/useCloudClient"; +import type { CloudProvider, ConversationItem } from "../../../types"; + +function formatTime(timestamp: number) { + try { + return new Date(timestamp).toLocaleTimeString(); + } catch { + return ""; + } +} + +function itemLabel(item: ConversationItem) { + if (item.kind === "message") { + return item.role === "assistant" ? "assistant" : "user"; + } + if (item.kind === "reasoning") { + return "reasoning"; + } + if (item.kind === "tool") { + return item.toolType; + } + if (item.kind === "diff") { + return "diff"; + } + return item.kind; +} + +export function CloudClientApp() { + const { settings: appSettings, setSettings: setAppSettings, saveSettings } = + useAppSettings(); + const { + runnerId, + workspaces, + activeWorkspaceId, + setActiveWorkspaceId, + threadId, + items, + busy, + logs, + e2eStatus, + e2eDetail, + connectionLabel, + checkNats, + discover, + loadWorkspaces, + connectWorkspace, + startThread, + sendText, + e2eRun, + } = useCloudClient(); + + const isE2E = import.meta.env.VITE_E2E === "1"; + const ranE2ERef = useRef(false); + + const [natsUrlDraft, setNatsUrlDraft] = useState(appSettings.natsUrl ?? ""); + const [messageDraft, setMessageDraft] = useState(""); + + useEffect(() => { + setNatsUrlDraft(appSettings.natsUrl ?? ""); + }, [appSettings.natsUrl]); + + const provider = (appSettings.cloudProvider ?? "local") as CloudProvider; + const providerDraft = provider; + + const canUseCloud = providerDraft === "nats"; + const selectedWorkspace = useMemo( + () => workspaces.find((ws) => ws.id === activeWorkspaceId) ?? null, + [activeWorkspaceId, workspaces], + ); + + useEffect(() => { + if (!isE2E || ranE2ERef.current) { + return; + } + ranE2ERef.current = true; + void e2eRun(); + }, [e2eRun, isE2E]); + + async function saveCloudSettings(nextProvider: CloudProvider) { + const next = { + ...appSettings, + cloudProvider: nextProvider, + natsUrl: natsUrlDraft.trim(), + }; + setAppSettings(next); + await saveSettings(next); + } + + const logsCard = ( +
+
Logs
+
+ {logs.length === 0 ? ( +
(no logs yet)
+ ) : ( + logs.map((entry) => ( +
+ {formatTime(entry.at)}{" "} + {entry.message} +
+ )) + )} +
+
+ ); + + return ( +
+
+
+
CodexMonitor Cloud Client
+
+ Provider: {providerDraft}{" "} + Runner:{" "} + {runnerId ?? "(none)"} +
+
+
+ + +
+
+ +
+
+
+
Connection
+ + +
+ + + +
+ {!canUseCloud && ( +
+ Select nats to control a + macOS runner. +
+ )} +
+ +
+
Workspaces
+
+ + + +
+
+ {workspaces.length === 0 ? ( +
(no workspaces)
+ ) : ( + workspaces.map((ws) => ( + + )) + )} +
+
+ Selected:{" "} + + {selectedWorkspace?.name ?? "(none)"} + +
+
+ +
+
E2E
+
+ +
+ {e2eStatus} + {e2eDetail ? ` — ${e2eDetail}` : ""} +
+
+ {isE2E && ( +
+ Auto-run: VITE_E2E=1 +
+ )} +
+ +
{logsCard}
+
+ +
+
+
+
+
Chat
+
+ Workspace:{" "} + + {connectionLabel.workspace?.name ?? "(none)"} + {" "} + · Thread:{" "} + {threadId ?? "(none)"} +
+
+
+ +
+ {items.length === 0 ? ( +
(no messages yet)
+ ) : ( + items.map((item) => ( +
+
{itemLabel(item)}
+
+                      {item.kind === "message"
+                        ? item.text
+                        : item.kind === "reasoning"
+                          ? `${item.summary}\n\n${item.content}`.trim()
+                          : item.kind === "diff"
+                            ? item.diff
+                            : item.kind === "tool"
+                              ? [item.title, item.detail, item.output]
+                                  .filter(Boolean)
+                                  .join("\n")
+                              : JSON.stringify(item, null, 2)}
+                    
+
+ )) + )} +
+ +
+ setMessageDraft(event.target.value)} + disabled={busy || !runnerId || !activeWorkspaceId || !threadId} + /> + +
+
+
+ +
+
{logsCard}
+
+
+
+ ); +} diff --git a/src/features/cloudClient/hooks/useCloudClient.ts b/src/features/cloudClient/hooks/useCloudClient.ts new file mode 100644 index 000000000..43f57b1da --- /dev/null +++ b/src/features/cloudClient/hooks/useCloudClient.ts @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { cloudDiscoverRunner, cloudRpc, natsStatus } from "../../../services/tauri"; +import { buildItemsFromThread } from "../../../utils/threadItems"; +import type { ConversationItem, WorkspaceInfo } from "../../../types"; + +type CloudClientLogEntry = { at: number; message: string }; + +type E2EStatus = "idle" | "running" | "pass" | "fail"; + +function asString(value: unknown) { + return typeof value === "string" ? value : value ? String(value) : ""; +} + +function extractThreadId(response: unknown) { + if (typeof response === "string") { + return response; + } + if (!response || typeof response !== "object") { + return null; + } + const record = response as Record; + const direct = asString(record.threadId ?? record.thread_id ?? record.id); + return direct || null; +} + +function lastAssistantMessage(items: ConversationItem[]) { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item.kind === "message" && item.role === "assistant" && item.text.trim()) { + return item.text.trim(); + } + } + return null; +} + +export function useCloudClient() { + const [runnerId, setRunnerId] = useState(null); + const [workspaces, setWorkspaces] = useState([]); + const [activeWorkspaceId, setActiveWorkspaceId] = useState(null); + const [threadId, setThreadId] = useState(null); + const [items, setItems] = useState([]); + const [busy, setBusy] = useState(false); + const [logs, setLogs] = useState([]); + const [e2eStatus, setE2eStatus] = useState("idle"); + const [e2eDetail, setE2eDetail] = useState(""); + + const pollTimerRef = useRef(null); + const isMountedRef = useRef(true); + + const appendLog = useCallback((message: string) => { + const entry = { at: Date.now(), message }; + console.log(`[cloud] ${message}`); + setLogs((prev) => [...prev, entry].slice(-120)); + }, []); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + if (pollTimerRef.current) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }; + }, []); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + window.clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + }, []); + + const pollThread = useCallback( + async (nextRunnerId: string, workspaceId: string, nextThreadId: string) => { + const thread = await cloudRpc(nextRunnerId, "resume_thread", { + workspaceId, + threadId: nextThreadId, + }); + const nextItems = buildItemsFromThread((thread ?? {}) as Record); + if (!isMountedRef.current) { + return nextItems; + } + setItems(nextItems); + return nextItems; + }, + [], + ); + + const startPolling = useCallback( + (nextRunnerId: string, workspaceId: string, nextThreadId: string) => { + stopPolling(); + pollTimerRef.current = window.setInterval(() => { + void pollThread(nextRunnerId, workspaceId, nextThreadId).catch(() => {}); + }, 1500); + }, + [pollThread, stopPolling], + ); + + const checkNats = useCallback(async () => { + try { + const status = await natsStatus(); + appendLog( + status.ok + ? `NATS OK (${status.server ?? "connected"})` + : `NATS error: ${status.error ?? "unknown error"}`, + ); + return status.ok; + } catch (error) { + appendLog(`NATS error: ${asString(error)}`); + return false; + } + }, [appendLog]); + + const discover = useCallback(async () => { + setBusy(true); + try { + const found = await cloudDiscoverRunner(); + if (!found) { + appendLog("No runner discovered (is the macOS app running?)."); + setRunnerId(null); + return null; + } + appendLog(`Discovered runner: ${found}`); + setRunnerId(found); + return found; + } finally { + setBusy(false); + } + }, [appendLog]); + + const loadWorkspaces = useCallback( + async (nextRunnerId: string) => { + setBusy(true); + try { + const list = await cloudRpc(nextRunnerId, "list_workspaces", {}); + setWorkspaces(Array.isArray(list) ? list : []); + appendLog(`Loaded workspaces: ${Array.isArray(list) ? list.length : 0}`); + return Array.isArray(list) ? list : []; + } finally { + setBusy(false); + } + }, + [appendLog], + ); + + const connectWorkspace = useCallback( + async (nextRunnerId: string, workspaceId: string) => { + setBusy(true); + try { + await cloudRpc(nextRunnerId, "connect_workspace", { workspaceId }); + appendLog(`Connected workspace: ${workspaceId}`); + } finally { + setBusy(false); + } + }, + [appendLog], + ); + + const startThread = useCallback( + async (nextRunnerId: string, workspaceId: string) => { + setBusy(true); + try { + const response = await cloudRpc(nextRunnerId, "start_thread", { workspaceId }); + const nextThreadId = extractThreadId(response); + if (!nextThreadId) { + throw new Error(`Missing threadId in response: ${JSON.stringify(response)}`); + } + setThreadId(nextThreadId); + appendLog(`Started thread: ${nextThreadId}`); + await pollThread(nextRunnerId, workspaceId, nextThreadId); + startPolling(nextRunnerId, workspaceId, nextThreadId); + return nextThreadId; + } finally { + setBusy(false); + } + }, + [appendLog, pollThread, startPolling], + ); + + const sendText = useCallback( + async (nextRunnerId: string, workspaceId: string, nextThreadId: string, text: string) => { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + setBusy(true); + try { + await cloudRpc(nextRunnerId, "send_user_message", { + workspaceId, + threadId: nextThreadId, + text: trimmed, + accessMode: "current", + model: null, + effort: null, + images: [], + }); + appendLog(`Sent: ${trimmed}`); + await pollThread(nextRunnerId, workspaceId, nextThreadId); + } finally { + setBusy(false); + } + }, + [appendLog, pollThread], + ); + + const e2eRun = useCallback(async () => { + setE2eStatus("running"); + setE2eDetail("Starting..."); + appendLog("E2E: start"); + + try { + const natsOk = await checkNats(); + if (!natsOk) { + throw new Error("NATS not reachable."); + } + + const nextRunnerId = (await discover()) ?? ""; + if (!nextRunnerId) { + throw new Error("No runner discovered."); + } + setE2eDetail(`Runner: ${nextRunnerId}`); + + const ws = await loadWorkspaces(nextRunnerId); + const first = ws[0]; + if (!first) { + throw new Error("No workspaces found on runner."); + } + setActiveWorkspaceId(first.id); + setE2eDetail(`Workspace: ${first.name}`); + await connectWorkspace(nextRunnerId, first.id); + + const nextThreadId = await startThread(nextRunnerId, first.id); + setE2eDetail(`Thread: ${nextThreadId}`); + + await sendText(nextRunnerId, first.id, nextThreadId, "Erzähl mir bitte einen Witz."); + setE2eDetail("Waiting for assistant reply..."); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const nextItems = await pollThread(nextRunnerId, first.id, nextThreadId); + const assistant = lastAssistantMessage(nextItems); + if (assistant) { + appendLog("E2E: pass"); + setE2eStatus("pass"); + setE2eDetail("PASS"); + document.title = "E2E PASS"; + return; + } + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + throw new Error("Timed out waiting for assistant reply."); + } catch (error) { + const detail = asString(error) || "E2E failed."; + appendLog(`E2E: fail: ${detail}`); + setE2eStatus("fail"); + setE2eDetail(detail); + document.title = "E2E FAIL"; + } + }, [ + appendLog, + checkNats, + connectWorkspace, + discover, + loadWorkspaces, + pollThread, + sendText, + startThread, + ]); + + const connectionLabel = useMemo(() => { + const workspace = workspaces.find((ws) => ws.id === activeWorkspaceId) ?? null; + return { + runnerId, + workspace, + threadId, + }; + }, [activeWorkspaceId, runnerId, threadId, workspaces]); + + return { + runnerId, + workspaces, + activeWorkspaceId, + setActiveWorkspaceId, + threadId, + setThreadId, + items, + busy, + logs, + e2eStatus, + e2eDetail, + connectionLabel, + stopPolling, + checkNats, + discover, + loadWorkspaces, + connectWorkspace, + startThread, + sendText, + e2eRun, + }; +} + diff --git a/src/features/composer/components/ComposerInput.tsx b/src/features/composer/components/ComposerInput.tsx index 13cd983e9..b34d93649 100644 --- a/src/features/composer/components/ComposerInput.tsx +++ b/src/features/composer/components/ComposerInput.tsx @@ -193,6 +193,7 @@ export function ComposerInput({