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/package-lock.json b/package-lock.json index b8d2c5026..7eb3e77ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2127,7 +2126,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.8.tgz", "integrity": "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2188,7 +2186,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -2510,8 +2507,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", @@ -2519,7 +2515,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2872,7 +2867,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3690,7 +3684,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6758,7 +6751,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6920,7 +6912,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6930,7 +6921,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8057,7 +8047,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8247,7 +8236,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", 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..ecfbe9007 --- /dev/null +++ b/src-tauri/src/git_mobile.rs @@ -0,0 +1,148 @@ +use tauri::State; + +use crate::state::AppState; +use crate::types::{ + GitFileDiff, GitHubIssuesResponse, GitHubPullRequestDiff, GitHubPullRequestsResponse, + GitHubPullRequestComment, 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 list_git_roots( + _workspace_id: String, + _depth: Option, + _state: State<'_, AppState>, +) -> Result, String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn stage_git_file( + _workspace_id: String, + _path: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn unstage_git_file( + _workspace_id: String, + _path: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn revert_git_file( + _workspace_id: String, + _path: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + Err(unsupported()) +} + +#[tauri::command] +pub(crate) async fn revert_git_all( + _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 get_github_pull_request_comments( + _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/git_utils_mobile.rs b/src-tauri/src/git_utils_mobile.rs new file mode 100644 index 000000000..dcea5e002 --- /dev/null +++ b/src-tauri/src/git_utils_mobile.rs @@ -0,0 +1,27 @@ +use std::path::{Path, PathBuf}; + +use crate::types::WorkspaceEntry; + +pub(crate) fn resolve_git_root(entry: &WorkspaceEntry) -> Result { + let base = PathBuf::from(&entry.path); + let root = entry + .settings + .git_root + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let Some(root) = root else { + return Ok(base); + }; + let root_path = if Path::new(root).is_absolute() { + PathBuf::from(root) + } else { + base.join(root) + }; + if root_path.is_dir() { + Ok(root_path) + } else { + Err(format!("Git root not found: {root}")) + } +} + diff --git a/src-tauri/src/integrations/mod.rs b/src-tauri/src/integrations/mod.rs new file mode 100644 index 000000000..c217fb57a --- /dev/null +++ b/src-tauri/src/integrations/mod.rs @@ -0,0 +1,809 @@ +#[cfg(not(mobile))] +use std::time::Duration; + +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; +mod telegram; + +#[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, + telegram: 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<()>, +} + +pub(crate) struct TelegramRuntime { + handle: JoinHandle<()>, + tx: mpsc::UnboundedSender, +} + +impl Default for IntegrationsRuntime { + fn default() -> Self { + Self { + cloud: None, + cloud_listener: None, + telegram: 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(); + } + } + + fn stop_telegram(&mut self) { + if let Some(runtime) = self.telegram.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_telegram(); + 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_telegram = app.clone(); + 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; + if integrations.telegram.is_none() { + let (tx, rx) = mpsc::unbounded_channel::(); + integrations.telegram = Some(TelegramRuntime { + handle: tokio::spawn(async move { + telegram::telegram_loop(app_for_telegram, rx).await; + }), + tx, + }); + } + + let provider = settings.cloud_provider.clone(); + let nats_config = if matches!(provider, CloudProvider::Nats) { + Some(nats::NatsConnectConfig { + url: settings.nats_url.clone().unwrap_or_default(), + auth_mode: settings.nats_auth_mode.clone(), + username: settings.nats_username.clone(), + password: settings.nats_password.clone(), + creds: settings.nats_creds.clone(), + }) + } else { + None + }; + let config = match provider { + CloudProvider::Nats => nats_config.as_ref().map(nats::config_key), + 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_config = nats_config.unwrap_or(nats::NatsConnectConfig { + url: settings.nats_url.clone().unwrap_or_default(), + auth_mode: settings.nats_auth_mode.clone(), + username: settings.nats_username.clone(), + password: settings.nats_password.clone(), + creds: settings.nats_creds.clone(), + }); + tokio::spawn(async move { + nats::run_nats_cloud(app_for_task, runner_id, nats_config, 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; + }; + if let Some(telegram) = integrations.telegram.as_ref() { + let _ = telegram.tx.send(telegram::TelegramEvent::AppServerEvent { + workspace_id: event.workspace_id.clone(), + message: event.message.clone(), + }); + } + 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 state = app.state::(); + let prompts = crate::prompts::prompts_list(state, 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 config = nats::NatsConnectConfig { + url: settings.nats_url.clone().unwrap_or_default(), + auth_mode: settings.nats_auth_mode.clone(), + username: settings.nats_username.clone(), + password: settings.nats_password.clone(), + creds: settings.nats_creds.clone(), + }; + nats::nats_status(&config).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 telegram_bot_status(app: AppHandle) -> Result { + telegram::bot_status(app).await +} + +#[tauri::command] +pub(crate) async fn telegram_register_link(app: AppHandle) -> Result { + telegram::register_link(app).await +} + +#[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 config = nats::NatsConnectConfig { + url: settings + .nats_url + .ok_or("NATS URL not configured.".to_string())?, + auth_mode: settings.nats_auth_mode, + username: settings.nats_username, + password: settings.nats_password, + creds: settings.nats_creds, + }; + nats::nats_discover_runner(&config, 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 config = nats::NatsConnectConfig { + url: settings + .nats_url + .ok_or("NATS URL not configured.".to_string())?, + auth_mode: settings.nats_auth_mode, + username: settings.nats_username, + password: settings.nats_password, + creds: settings.nats_creds, + }; + 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( + &config, + 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 nats_config = if matches!(provider, CloudProvider::Nats) { + Some(nats::NatsConnectConfig { + url: settings.nats_url.clone().unwrap_or_default(), + auth_mode: settings.nats_auth_mode.clone(), + username: settings.nats_username.clone(), + password: settings.nats_password.clone(), + creds: settings.nats_creds.clone(), + }) + } else { + None + }; + let config = match provider { + CloudProvider::Nats => nats_config.as_ref().map(nats::config_key), + 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 nats_connect_config = nats_config.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, nats_connect_config) + .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..8f59dee4b --- /dev/null +++ b/src-tauri/src/integrations/nats.rs @@ -0,0 +1,307 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +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}; +use crate::types::NatsAuthMode; + +#[derive(Clone, Debug)] +pub(crate) struct NatsConnectConfig { + pub(crate) url: String, + pub(crate) auth_mode: NatsAuthMode, + pub(crate) username: Option, + pub(crate) password: Option, + pub(crate) creds: Option, +} + +pub(crate) fn config_key(config: &NatsConnectConfig) -> String { + let mut hasher = DefaultHasher::new(); + config.url.hash(&mut hasher); + config.auth_mode.hash(&mut hasher); + config.username.hash(&mut hasher); + config.password.hash(&mut hasher); + config.creds.hash(&mut hasher); + format!("nats:{:x}", hasher.finish()) +} + +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())) +} + +fn strip_auth(url: &str) -> String { + let Ok(mut parsed) = url::Url::parse(url) else { + return url.to_string(); + }; + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.to_string() +} + +async fn connect(config: &NatsConnectConfig) -> Result { + let url = config.url.trim(); + if url.is_empty() { + return Err("NATS URL is empty.".to_string()); + } + + let mut opts = ConnectOptions::new(); + let url = match config.auth_mode { + NatsAuthMode::Url => { + let (url, token) = parse_nats_auth(url); + if let Some(token) = token { + opts = opts.token(token); + } + url + } + NatsAuthMode::Userpass => { + let user = config + .username + .as_deref() + .unwrap_or("") + .trim() + .to_string(); + let pass = config.password.as_deref().unwrap_or("").to_string(); + if user.is_empty() || pass.is_empty() { + return Err("NATS username/password missing.".to_string()); + } + opts = opts.user_and_password(user, pass); + strip_auth(url) + } + NatsAuthMode::Creds => { + let creds = config.creds.as_deref().unwrap_or("").trim(); + if creds.is_empty() { + return Err("NATS creds are empty.".to_string()); + } + opts = opts + .credentials(creds) + .map_err(|e| format!("Failed to parse NATS creds: {e}"))?; + strip_auth(url) + } + }; + + opts.connect(url) + .await + .map_err(|error| format!("Failed to connect to NATS: {error}")) +} + +pub(crate) async fn nats_status(config: &NatsConnectConfig) -> Result { + let client = match connect(config).await { + Ok(client) => client, + Err(error) => { + return Ok(NatsStatus { + ok: false, + server: None, + error: Some(error), + }); + } + }; + 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, + config: NatsConnectConfig, + 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(&config).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( + config: &NatsConnectConfig, + subject: String, + payload: String, + timeout_ms: u64, +) -> Result { + let client = connect(config).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( + config: &NatsConnectConfig, + timeout_ms: u64, +) -> Result, String> { + let client = connect(config).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, + config: NatsConnectConfig, +) { + let subject = format!("cm.ev.{runner_id}.*"); + loop { + let client = match connect(&config).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/integrations/telegram.rs b/src-tauri/src/integrations/telegram.rs new file mode 100644 index 000000000..7330531dc --- /dev/null +++ b/src-tauri/src/integrations/telegram.rs @@ -0,0 +1,1064 @@ +use std::collections::{HashMap, HashSet}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tauri::{AppHandle, Manager}; +use tokio::sync::mpsc; + +#[cfg(not(mobile))] +use std::time::Duration; +#[cfg(not(mobile))] +use tokio::time::sleep; + +use crate::state::AppState; +use crate::storage::write_settings; +use crate::types::AppSettings; + +#[derive(Debug, Clone)] +pub(crate) enum TelegramEvent { + AppServerEvent { workspace_id: String, message: Value }, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct TelegramBotStatus { + pub(crate) ok: bool, + pub(crate) username: Option, + pub(crate) id: Option, + pub(crate) error: Option, +} + +#[derive(Debug, Clone)] +struct TelegramConfig { + enabled: bool, + token: Option, + allowed_user_ids: HashSet, + pairing_secret: String, +} + +fn read_config(settings: &AppSettings) -> TelegramConfig { + TelegramConfig { + enabled: settings.telegram_enabled, + token: settings + .telegram_bot_token + .clone() + .filter(|value| !value.trim().is_empty()), + allowed_user_ids: settings + .telegram_allowed_user_ids + .clone() + .unwrap_or_default() + .into_iter() + .collect(), + pairing_secret: settings.telegram_pairing_secret.clone(), + } +} + +fn pairing_code(secret: &str) -> String { + let mut filtered: String = secret + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect(); + if filtered.is_empty() { + filtered = "unknown".to_string(); + } + if filtered.len() > 32 { + filtered.truncate(32); + } + filtered.to_lowercase() +} + +fn build_register_payload(secret: &str) -> String { + format!("link_{}", pairing_code(secret)) +} + +fn build_inline_keyboard(rows: Vec>) -> Value { + json!({ + "inline_keyboard": rows.into_iter().map(|row| { + row.into_iter().map(|(text, data)| json!({ "text": text, "callback_data": data})).collect::>() + }).collect::>() + }) +} + +#[derive(Debug, Deserialize)] +struct TelegramResponse { + ok: bool, + result: Option, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramBotInfo { + id: i64, + username: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + update_id: i64, + message: Option, + callback_query: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramCallbackQuery { + id: Option, + from: Option, + message: Option, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUser { + id: i64, +} + +#[derive(Debug, Deserialize)] +struct TelegramMessage { + message_id: i64, + chat: TelegramChat, + from: Option, + text: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramChat { + id: i64, +} + +#[derive(Clone)] +struct TelegramApi { + client: reqwest::Client, +} + +impl TelegramApi { + fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + async fn call( + &self, + token: &str, + method: &str, + params: &[(&str, String)], + ) -> Result { + let url = format!("https://api.telegram.org/bot{token}/{method}"); + let response = self + .client + .post(&url) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Telegram request failed: {e}"))?; + response + .json::() + .await + .map_err(|e| format!("Telegram decode failed: {e}")) + } + + async fn call_get( + &self, + token: &str, + method: &str, + params: &[(&str, String)], + ) -> Result { + let url = format!("https://api.telegram.org/bot{token}/{method}"); + let response = self + .client + .get(&url) + .query(¶ms) + .send() + .await + .map_err(|e| format!("Telegram request failed: {e}"))?; + response + .json::() + .await + .map_err(|e| format!("Telegram decode failed: {e}")) + } + + async fn get_me(&self, token: &str) -> Result { + let response: TelegramResponse = + self.call_get(token, "getMe", &[]).await?; + if response.ok { + response + .result + .ok_or_else(|| "Telegram getMe returned no result.".to_string()) + } else { + Err(response + .description + .unwrap_or_else(|| "Telegram getMe failed.".to_string())) + } + } + + async fn get_updates( + &self, + token: &str, + offset: Option, + timeout_seconds: u64, + ) -> Result, String> { + let mut params = vec![("timeout", timeout_seconds.to_string())]; + if let Some(offset) = offset { + params.push(("offset", offset.to_string())); + } + let response: TelegramResponse> = + self.call_get(token, "getUpdates", ¶ms).await?; + if response.ok { + Ok(response.result.unwrap_or_default()) + } else { + Err(response + .description + .unwrap_or_else(|| "Telegram getUpdates failed.".to_string())) + } + } + + async fn send_message( + &self, + token: &str, + chat_id: i64, + text: &str, + reply_markup: Option, + ) -> Result<(), String> { + let mut params = vec![ + ("chat_id", chat_id.to_string()), + ("text", text.to_string()), + ]; + if let Some(markup) = reply_markup { + params.push(("reply_markup", markup.to_string())); + } + let response: TelegramResponse = self.call(token, "sendMessage", ¶ms).await?; + if response.ok { + Ok(()) + } else { + Err(response + .description + .unwrap_or_else(|| "Telegram sendMessage failed.".to_string())) + } + } + + async fn answer_callback_query(&self, token: &str, id: &str) -> Result<(), String> { + let params = vec![("callback_query_id", id.to_string())]; + let response: TelegramResponse = + self.call(token, "answerCallbackQuery", ¶ms).await?; + if response.ok { + Ok(()) + } else { + Err(response + .description + .unwrap_or_else(|| "Telegram answerCallbackQuery failed.".to_string())) + } + } +} + +pub(crate) async fn bot_status(app: AppHandle) -> Result { + #[cfg(mobile)] + { + let _ = app; + return Ok(TelegramBotStatus { + ok: false, + username: None, + id: None, + error: Some("Telegram is not supported on mobile yet.".to_string()), + }); + } + + #[cfg(not(mobile))] + { + let state = app.state::(); + let settings = state.app_settings.lock().await; + let config = read_config(&settings); + let token = config + .token + .ok_or_else(|| "Telegram token is not configured.".to_string())?; + let api = TelegramApi::new(); + match api.get_me(&token).await { + Ok(info) => Ok(TelegramBotStatus { + ok: true, + username: info.username, + id: Some(info.id), + error: None, + }), + Err(err) => Ok(TelegramBotStatus { + ok: false, + username: None, + id: None, + error: Some(err), + }), + } + } +} + +pub(crate) async fn register_link(app: AppHandle) -> Result { + #[cfg(mobile)] + { + let _ = app; + return Err("Telegram is not supported on mobile yet.".to_string()); + } + + #[cfg(not(mobile))] + { + let state = app.state::(); + let settings = state.app_settings.lock().await; + let config = read_config(&settings); + let token = config + .token + .ok_or_else(|| "Telegram token is not configured.".to_string())?; + let api = TelegramApi::new(); + let info = api.get_me(&token).await?; + let username = info + .username + .ok_or_else(|| "Telegram bot username not available (getMe returned none).".to_string())?; + let payload = build_register_payload(&config.pairing_secret); + Ok(format!("https://t.me/{username}?start={payload}")) + } +} + +#[cfg(mobile)] +pub(crate) async fn telegram_loop(_app: AppHandle, mut rx: mpsc::UnboundedReceiver) { + while rx.recv().await.is_some() {} +} + +#[cfg(not(mobile))] +pub(crate) async fn telegram_loop(app: AppHandle, mut rx: mpsc::UnboundedReceiver) { + let api = TelegramApi::new(); + let mut offset: Option = None; + let mut selected_thread: HashMap = HashMap::new(); + let mut thread_tokens: HashMap = HashMap::new(); + let mut last_sent_by_chat_thread: HashMap = HashMap::new(); + + loop { + let config = { + let state = app.state::(); + let settings = state.app_settings.lock().await; + read_config(&settings) + }; + + if !config.enabled { + sleep(Duration::from_millis(800)).await; + continue; + } + + let Some(token) = config.token.clone() else { + sleep(Duration::from_millis(800)).await; + continue; + }; + + let updates = tokio::select! { + event = rx.recv() => { + if let Some(event) = event { + handle_app_server_event( + &api, + &token, + &app, + &config, + &selected_thread, + &mut last_sent_by_chat_thread, + event, + ) + .await; + } + continue; + } + updates = api.get_updates(&token, offset, 20) => { + match updates { + Ok(updates) => updates, + Err(err) => { + eprintln!("[telegram] {err}"); + sleep(Duration::from_secs(2)).await; + continue; + } + } + } + }; + + for update in updates { + offset = Some(update.update_id + 1); + + if let Some(callback) = update.callback_query { + let chat_id = callback + .message + .as_ref() + .map(|m| m.chat.id) + .unwrap_or_default(); + let Some(user_id) = callback.from.as_ref().map(|u| u.id) else { + continue; + }; + let Some(data) = callback.data.clone() else { + continue; + }; + + if let Some(id) = callback.id.as_deref() { + let _ = api.answer_callback_query(&token, id).await; + } + + if !config.allowed_user_ids.contains(&user_id) { + let _ = api + .send_message( + &token, + chat_id, + "Not paired yet. Use the Register link in CodexMonitor Settings → Cloud → Telegram.", + None, + ) + .await; + continue; + } + + if let Some(workspace_id) = data.strip_prefix("ws:") { + match list_threads_for_workspace(&app, workspace_id).await { + Ok(threads) => { + thread_tokens.retain(|_, (cid, _, _)| *cid != chat_id); + let mut rows: Vec> = Vec::new(); + if threads.is_empty() { + rows.push(vec![( + "🆕 New thread".to_string(), + format!("new:{workspace_id}"), + )]); + } else { + for (thread_id, label) in threads { + let token_id = uuid::Uuid::new_v4().to_string(); + let short = token_id + .chars() + .filter(|c| c.is_ascii_hexdigit()) + .take(10) + .collect::(); + thread_tokens.insert( + short.clone(), + (chat_id, workspace_id.to_string(), thread_id.clone()), + ); + rows.push(vec![(label, format!("th:{short}"))]); + } + } + rows.push(vec![("🔄 Workspaces".to_string(), "status".to_string())]); + let _ = api + .send_message( + &token, + chat_id, + "Select a thread:", + Some(build_inline_keyboard(rows)), + ) + .await; + } + Err(err) => { + let _ = api + .send_message(&token, chat_id, &format!("Error: {err}"), None) + .await; + } + } + continue; + } + + if data == "status" { + let _ = send_workspace_status(&api, &token, &app, chat_id).await; + continue; + } + + if let Some(workspace_id) = data.strip_prefix("new:") { + match start_new_thread(&app, workspace_id).await { + Ok(thread_id) => { + selected_thread + .insert(chat_id, (workspace_id.to_string(), thread_id.clone())); + let _ = api + .send_message( + &token, + chat_id, + "Created new thread. Send a message to start.", + None, + ) + .await; + } + Err(err) => { + let _ = api + .send_message(&token, chat_id, &format!("Error: {err}"), None) + .await; + } + } + continue; + } + + if let Some(token_key) = data.strip_prefix("th:") { + let Some((cid, ws, thread)) = thread_tokens.get(token_key).cloned() else { + let _ = api + .send_message(&token, chat_id, "Selection expired. Use /status.", None) + .await; + continue; + }; + if cid != chat_id { + continue; + } + match resume_thread(&app, &ws, &thread).await { + Ok(_) => { + selected_thread.insert(chat_id, (ws.clone(), thread.clone())); + let _ = api + .send_message( + &token, + chat_id, + &format!("Selected thread.\nWorkspace: {ws}\nThread: {thread}"), + None, + ) + .await; + } + Err(err) => { + let _ = api + .send_message( + &token, + chat_id, + &format!("Error selecting thread: {err}\n\nSend /status to pick another thread."), + None, + ) + .await; + } + } + continue; + } + } + + let Some(message) = update.message else { + continue; + }; + + let chat_id = message.chat.id; + let Some(user_id) = message.from.as_ref().map(|u| u.id) else { + continue; + }; + let text = message.text.unwrap_or_default(); + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.starts_with("/start") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 2 { + let payload = parts[1]; + if let Some(code) = payload.strip_prefix("link_") { + if code == pairing_code(&config.pairing_secret) { + if let Err(err) = pair_user(&app, user_id, chat_id).await { + let _ = api + .send_message(&token, chat_id, &format!("Error: {err}"), None) + .await; + } else { + let _ = api + .send_message( + &token, + chat_id, + "✅ Paired. Send /status to select a workspace.", + None, + ) + .await; + } + continue; + } + } + } + let _ = api + .send_message( + &token, + chat_id, + "🤖 CodexMonitor\n\nUse /status to pick a workspace.\n\nIf you haven't paired yet, use the Register link in Settings → Cloud → Telegram.", + None, + ) + .await; + continue; + } + + if trimmed.starts_with("/link") { + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + let code = parts.get(1).copied().unwrap_or(""); + if code == pairing_code(&config.pairing_secret) { + if let Err(err) = pair_user(&app, user_id, chat_id).await { + let _ = api + .send_message(&token, chat_id, &format!("Error: {err}"), None) + .await; + } else { + let _ = api + .send_message( + &token, + chat_id, + "✅ Paired. Send /status to select a workspace.", + None, + ) + .await; + } + } else { + let _ = api + .send_message( + &token, + chat_id, + "Invalid code. Use the Register link in CodexMonitor Settings → Cloud → Telegram.", + None, + ) + .await; + } + continue; + } + + if trimmed == "/disconnect" { + selected_thread.remove(&chat_id); + let _ = api + .send_message(&token, chat_id, "Disconnected.", None) + .await; + continue; + } + + if trimmed == "/status" { + if !config.allowed_user_ids.contains(&user_id) { + let _ = api + .send_message( + &token, + chat_id, + "Not paired yet. Use the Register link in CodexMonitor Settings → Cloud → Telegram.", + None, + ) + .await; + continue; + } + let _ = send_workspace_status(&api, &token, &app, chat_id).await; + continue; + } + + if !config.allowed_user_ids.contains(&user_id) { + let _ = api + .send_message( + &token, + chat_id, + "Not paired yet. Use the Register link in CodexMonitor Settings → Cloud → Telegram.", + None, + ) + .await; + continue; + } + + let Some((workspace_id, thread_id)) = selected_thread.get(&chat_id).cloned() else { + let _ = api + .send_message(&token, chat_id, "No active thread. Send /status.", None) + .await; + continue; + }; + + match send_text_to_thread(&app, &workspace_id, &thread_id, trimmed).await { + Ok(_) => { + let _ = api + .send_message(&token, chat_id, "Sent.", None) + .await; + } + Err(err) => { + let _ = api + .send_message(&token, chat_id, &format!("Error: {err}"), None) + .await; + } + } + } + } +} + +async fn resume_thread(app: &AppHandle, workspace_id: &str, thread_id: &str) -> Result<(), String> { + if thread_id.trim().is_empty() { + return Err("missing thread id".to_string()); + } + 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 response = session + .send_request("thread/resume", json!({ "threadId": thread_id })) + .await?; + if let Some(err) = response.get("error") { + let message = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(message.to_string()); + } + Ok(()) +} + +async fn handle_app_server_event( + api: &TelegramApi, + token: &str, + app: &AppHandle, + config: &TelegramConfig, + selected_thread: &HashMap, + last_sent_by_chat_thread: &mut HashMap, + event: TelegramEvent, +) { + let TelegramEvent::AppServerEvent { workspace_id, message } = event; + let method = message.get("method").and_then(|v| v.as_str()).unwrap_or(""); + if method != "turn/completed" && method != "turn/failed" && method != "error" { + return; + } + let thread_id = extract_thread_id(&message); + let Some(thread_id) = thread_id else { + return; + }; + if !config.enabled { + return; + } + if config.token.as_deref().unwrap_or("").trim().is_empty() { + return; + } + + for (chat_id, (ws, th)) in selected_thread.iter() { + if ws != &workspace_id || th != &thread_id { + continue; + } + let _ = send_latest_assistant_reply( + api, + token, + app, + *chat_id, + ws, + th, + last_sent_by_chat_thread, + ) + .await; + } +} + +fn extract_thread_id(message: &Value) -> Option { + let params = message.get("params").cloned().unwrap_or(Value::Null); + if let Some(id) = params.get("threadId").or_else(|| params.get("thread_id")).and_then(|v| v.as_str()) { + if !id.trim().is_empty() { + return Some(id.to_string()); + } + } + let turn = params.get("turn").cloned().unwrap_or(Value::Null); + if let Some(id) = turn.get("threadId").or_else(|| turn.get("thread_id")).and_then(|v| v.as_str()) { + if !id.trim().is_empty() { + return Some(id.to_string()); + } + } + None +} + +fn split_text(value: &str, limit: usize) -> Vec { + if value.trim().is_empty() { + return Vec::new(); + } + if value.chars().count() <= limit { + return vec![value.to_string()]; + } + let mut out: Vec = Vec::new(); + let mut buf = String::new(); + for ch in value.chars() { + if buf.chars().count() + 1 > limit { + out.push(buf); + buf = String::new(); + } + buf.push(ch); + } + if !buf.is_empty() { + out.push(buf); + } + out +} + +async fn send_latest_assistant_reply( + api: &TelegramApi, + token: &str, + app: &AppHandle, + chat_id: i64, + workspace_id: &str, + thread_id: &str, + last_sent_by_chat_thread: &mut HashMap, +) -> Result<(), String> { + 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 response = session + .send_request("thread/resume", json!({ "threadId": thread_id })) + .await?; + let result = response.get("result").cloned().unwrap_or(response); + let thread = result + .get("thread") + .cloned() + .or_else(|| result.get("result").cloned()) + .unwrap_or(Value::Null); + let turns = thread.get("turns").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + + let mut last_id: Option = None; + let mut last_text: Option = None; + for turn in turns { + let items = turn.get("items").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + for item in items { + let ty = item.get("type").and_then(|v| v.as_str()).unwrap_or(""); + if ty == "agentMessage" { + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let text = item.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if !id.is_empty() && !text.trim().is_empty() { + last_id = Some(id); + last_text = Some(text); + } + } + } + } + + let Some(last_id_value) = last_id else { + return Ok(()); + }; + let Some(last_text_value) = last_text else { + return Ok(()); + }; + + let key = format!("{chat_id}:{workspace_id}:{thread_id}"); + if last_sent_by_chat_thread.get(&key).map(|v| v == &last_id_value).unwrap_or(false) { + return Ok(()); + } + last_sent_by_chat_thread.insert(key, last_id_value); + + for chunk in split_text(&last_text_value, 3800) { + api.send_message(token, chat_id, &chunk, None).await?; + } + Ok(()) +} + +async fn pair_user(app: &AppHandle, user_id: i64, chat_id: i64) -> Result<(), String> { + let state = app.state::(); + let mut settings = state.app_settings.lock().await; + let mut allowed = settings.telegram_allowed_user_ids.clone().unwrap_or_default(); + if !allowed.contains(&user_id) { + allowed.push(user_id); + allowed.sort_unstable(); + settings.telegram_allowed_user_ids = Some(allowed); + } + if settings.telegram_default_chat_id.is_none() { + settings.telegram_default_chat_id = Some(chat_id); + } + let next = settings.clone(); + drop(settings); + write_settings(&state.settings_path, &next)?; + Ok(()) +} + +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 send_workspace_status( + api: &TelegramApi, + token: &str, + app: &AppHandle, + chat_id: i64, +) -> Result<(), String> { + let state = app.state::(); + let workspaces = crate::workspaces::list_workspaces(state).await?; + if workspaces.is_empty() { + api.send_message(token, chat_id, "No workspaces.", None).await?; + return Ok(()); + } + let mut rows: Vec> = Vec::new(); + for ws in workspaces { + rows.push(vec![(ws.name, format!("ws:{}", ws.id))]); + } + api.send_message( + token, + chat_id, + "Select a workspace:", + Some(build_inline_keyboard(rows)), + ) + .await +} + +async fn list_threads_for_workspace( + app: &AppHandle, + workspace_id: &str, +) -> Result, String> { + 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 workspace_path = session.entry.path.clone(); + let canonical_workspace = std::fs::canonicalize(&workspace_path) + .ok() + .and_then(|p| p.to_str().map(|s| s.to_string())); + + // Match the old bot behavior: fetch a few pages and filter by cwd (with canonical fallback). + let mut cursor: Option = None; + let mut collected: Vec = Vec::new(); + for _ in 0..3 { + let response = session + .send_request( + "thread/list", + json!({ + "cursor": cursor, + "limit": 40, + }), + ) + .await?; + let result = response.get("result").cloned().unwrap_or(response.clone()); + let data = result + .get("data") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + collected.extend(data); + + let next_cursor = result + .get("nextCursor") + .or_else(|| result.get("next_cursor")) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + cursor = next_cursor; + if cursor.is_none() || collected.len() >= 80 { + break; + } + } + + let mut threads: Vec<(String, String)> = Vec::new(); + for item in collected { + let Some(cwd) = item.get("cwd").and_then(|v| v.as_str()) else { + continue; + }; + let cwd_matches = if cwd == workspace_path { + true + } else if let (Some(cws), Ok(ccwd)) = + (canonical_workspace.as_deref(), std::fs::canonicalize(cwd)) + { + ccwd.to_str().is_some_and(|value| value == cws) + } else { + false + }; + if !cwd_matches { + continue; + } + + let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string(); + if id.is_empty() { + continue; + } + let preview = item + .get("preview") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let title = item + .get("title") + .or_else(|| item.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let label_source = if !preview.is_empty() { + preview + } else if !title.is_empty() { + title + } else { + "Agent" + }; + threads.push((id, label_source.to_string())); + } + + Ok(threads) +} + +async fn start_new_thread(app: &AppHandle, workspace_id: &str) -> Result { + 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 response = session + .send_request( + "thread/start", + json!({ "cwd": session.entry.path, "approvalPolicy": "on-request" }), + ) + .await?; + response + .get("result") + .and_then(|v| v.get("threadId").or_else(|| v.get("thread_id"))) + .and_then(|v| v.as_str()) + .map(|v| v.to_string()) + .ok_or_else(|| "thread/start did not return a thread id".to_string()) +} + +async fn send_text_to_thread( + app: &AppHandle, + workspace_id: &str, + thread_id: &str, + text: &str, +) -> Result<(), String> { + ensure_connected(app, workspace_id).await?; + let state = app.state::(); + let access_mode = { + let settings = state.app_settings.lock().await; + settings.default_access_mode.clone() + }; + + let sessions = state.sessions.lock().await; + let session = sessions + .get(workspace_id) + .ok_or("workspace not connected")?; + + // Important: resume first (old bot did this in multiple places). Some servers won't accept + // `turn/start` for a thread that isn't resumable from this session. + let resume_response = session + .send_request("thread/resume", json!({ "threadId": thread_id })) + .await?; + if let Some(err) = resume_response.get("error") { + let message = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(message.to_string()); + } + + 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 response = session + .send_request( + "turn/start", + json!({ + "threadId": thread_id, + "input": [json!({ "type": "text", "text": text.trim() })], + "cwd": session.entry.path, + "approvalPolicy": approval_policy, + "sandboxPolicy": sandbox_policy, + "model": Value::Null, + "effort": Value::Null, + }), + ) + .await?; + + if let Some(err) = response.get("error") { + let message = err + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + return Err(message.to_string()); + } + + let turn_id = response + .get("result") + .and_then(|result| result.get("turn")) + .and_then(|turn| turn.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + if turn_id.trim().is_empty() { + eprintln!("[telegram] turn/start response without turn id: {response}"); + } + Ok(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc3b23dda..453ff0b84 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,24 +1,45 @@ +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; +#[cfg(mobile)] +#[path = "git_utils_mobile.rs"] 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 +57,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 +236,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 +317,16 @@ 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, + integrations::telegram_bot_status, + integrations::telegram_register_link, + #[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..d6cbc2a10 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,13 +31,37 @@ pub(crate) async fn update_app_settings( settings: AppSettings, state: State<'_, AppState>, window: Window, + app: AppHandle, ) -> Result { + // Merge in bot-managed fields to prevent the UI from accidentally clearing + // persisted Telegram pairing state. + let merged = { + let current = state.app_settings.lock().await.clone(); + let mut merged = settings.clone(); + + if merged.telegram_allowed_user_ids.is_none() && current.telegram_allowed_user_ids.is_some() + { + merged.telegram_allowed_user_ids = current.telegram_allowed_user_ids.clone(); + } + if merged.telegram_default_chat_id.is_none() && current.telegram_default_chat_id.is_some() { + merged.telegram_default_chat_id = current.telegram_default_chat_id; + } + if merged.telegram_pairing_secret.trim().is_empty() + || merged.telegram_pairing_secret == "unknown" + { + merged.telegram_pairing_secret = current.telegram_pairing_secret.clone(); + } + + merged + }; + let _ = codex_config::write_collab_enabled(settings.experimental_collab_enabled); let _ = codex_config::write_steer_enabled(settings.experimental_steer_enabled); let _ = codex_config::write_unified_exec_enabled(settings.experimental_unified_exec_enabled); - write_settings(&state.settings_path, &settings)?; + write_settings(&state.settings_path, &merged)?; let mut current = state.app_settings.lock().await; - *current = settings.clone(); - let _ = window::apply_window_appearance(&window, settings.theme.as_str()); - Ok(settings) + *current = merged.clone(); + let _ = window::apply_window_appearance(&window, merged.theme.as_str()); + tauri::async_runtime::spawn(integrations::apply_settings(app)); + Ok(merged) } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 5465e697f..dc426ce84 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,42 @@ 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.telegram_pairing_secret.trim().is_empty() + || app_settings.telegram_pairing_secret == "unknown" + { + app_settings.telegram_pairing_secret = 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 +76,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..54d46ac2f 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -229,6 +229,47 @@ 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, PartialEq, Eq, Hash)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NatsAuthMode { + Url, + Userpass, + Creds, +} + +impl Default for NatsAuthMode { + fn default() -> Self { + NatsAuthMode::Url + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub(crate) struct AppSettings { #[serde(default, rename = "codexBin")] @@ -300,19 +341,37 @@ 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, rename = "natsAuthMode")] + pub(crate) nats_auth_mode: NatsAuthMode, + #[serde(default, rename = "natsUsername")] + pub(crate) nats_username: Option, + #[serde(default, rename = "natsPassword")] + pub(crate) nats_password: Option, + #[serde(default, rename = "natsCreds")] + pub(crate) nats_creds: 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, + #[serde( + default = "default_telegram_pairing_secret", + rename = "telegramPairingSecret" + )] + pub(crate) telegram_pairing_secret: String, } fn default_access_mode() -> String { @@ -375,6 +434,23 @@ 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 +} + +fn default_telegram_pairing_secret() -> String { + "unknown".to_string() +} + impl Default for AppSettings { fn default() -> Self { Self { @@ -399,6 +475,19 @@ 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(), + nats_auth_mode: NatsAuthMode::default(), + nats_username: None, + nats_password: None, + nats_creds: None, + cloudkit_container_id: default_cloudkit_container_id(), + telegram_enabled: false, + telegram_bot_token: None, + telegram_allowed_user_ids: None, + telegram_default_chat_id: None, + telegram_pairing_secret: default_telegram_pairing_secret(), } } } @@ -406,7 +495,9 @@ impl Default for AppSettings { #[cfg(test)] mod tests { use super::{ - AppSettings, BackendMode, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, WorkspaceSettings, + AppSettings, BackendMode, CloudProvider, NatsAuthMode, WorkspaceEntry, WorkspaceGroup, + WorkspaceKind, + WorkspaceSettings, }; #[test] @@ -439,6 +530,16 @@ 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!(matches!(settings.nats_auth_mode, NatsAuthMode::Url)); + assert!(settings.nats_username.is_none()); + assert!(settings.nats_password.is_none()); + assert!(settings.nats_creds.is_none()); + assert!(settings.cloudkit_container_id.is_none()); + assert!(!settings.telegram_enabled); + assert_eq!(settings.telegram_pairing_secret, "unknown"); assert!(settings.workspace_groups.is_empty()); } diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 0023552a2..2ea5c5cdf 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -1,4 +1,6 @@ -use tauri::{Theme, Window}; +use tauri::Window; +#[cfg(desktop)] +use tauri::Theme; #[cfg(test)] use std::sync::{Mutex, OnceLock}; @@ -47,6 +49,12 @@ fn apply_macos_window_appearance(window: &Window, theme: &str) -> Result<(), Str Ok(()) } +#[cfg(not(desktop))] +pub(crate) fn apply_window_appearance(_window: &Window, _theme: &str) -> Result<(), String> { + Ok(()) +} + +#[cfg(desktop)] pub(crate) fn apply_window_appearance(window: &Window, theme: &str) -> Result<(), String> { #[cfg(test)] if let Some(handler) = WINDOW_APPEARANCE_OVERRIDE 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..0f9dc2e96 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,17 @@ 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, + telegramBotStatus, + telegramRegisterLink, pickWorkspacePath, } from "./services/tauri"; +import { playNotificationSound } from "./utils/notificationSounds"; +import { isAppleMobileDevice } from "./utils/platform"; import type { AccessMode, GitHubPullRequest, @@ -108,6 +117,7 @@ function MainApp() { doctor, isLoading: appSettingsLoading } = useAppSettings(); + const isMobile = isAppleMobileDevice(); useThemePreference(appSettings.theme); const dictationModel = useDictationModel(appSettings.dictationModelId); const { @@ -193,6 +203,7 @@ function MainApp() { ); type SettingsSection = | "projects" + | "cloud" | "display" | "dictation" | "shortcuts" @@ -266,7 +277,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); @@ -325,9 +336,9 @@ function MainApp() { const { status: gitStatus, refresh: refreshGitStatus } = useGitStatus(activeWorkspace); const gitStatusRefreshTimeoutRef = useRef(null); - const activeWorkspaceIdRef = useRef(activeWorkspace?.id ?? null); + const gitStatusWorkspaceIdRef = useRef(activeWorkspace?.id ?? null); useEffect(() => { - activeWorkspaceIdRef.current = activeWorkspace?.id ?? null; + gitStatusWorkspaceIdRef.current = activeWorkspace?.id ?? null; }, [activeWorkspace?.id]); useEffect(() => { return () => { @@ -337,7 +348,7 @@ function MainApp() { }; }, []); const queueGitStatusRefresh = useCallback(() => { - const workspaceId = activeWorkspaceIdRef.current; + const workspaceId = gitStatusWorkspaceIdRef.current; if (!workspaceId) { return; } @@ -346,7 +357,7 @@ function MainApp() { } gitStatusRefreshTimeoutRef.current = window.setTimeout(() => { gitStatusRefreshTimeoutRef.current = null; - if (activeWorkspaceIdRef.current !== workspaceId) { + if (gitStatusWorkspaceIdRef.current !== workspaceId) { return; } refreshGitStatus(); @@ -1214,13 +1225,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 ? " mobile" : "" + }`; + const showRemoteRunnerNotice = isRemoteClient && !remoteRunnerId; const { sidebarNode, messagesNode, @@ -1264,6 +1506,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 +1815,13 @@ function MainApp() { } >
+ {isE2E ? ( +
+
E2E Joke Test
+
{e2eState.step}
+ {e2eState.error ?
{e2eState.error}
: null} +
+ ) : null} {isPhone ? ( { await updateWorkspaceCodexBin(id, codexBin); }} @@ -1709,6 +1971,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..cd2448003 --- /dev/null +++ b/src/features/cloudClient/components/CloudClientApp.tsx @@ -0,0 +1,433 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useAppSettings } from "../../settings/hooks/useAppSettings"; +import { useCloudClient } from "../hooks/useCloudClient"; +import type { CloudProvider, ConversationItem, NatsAuthMode } 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 [natsAuthModeDraft, setNatsAuthModeDraft] = useState( + appSettings.natsAuthMode, + ); + const [natsUsernameDraft, setNatsUsernameDraft] = useState(appSettings.natsUsername ?? ""); + const [natsPasswordDraft, setNatsPasswordDraft] = useState(appSettings.natsPassword ?? ""); + const [natsCredsDraft, setNatsCredsDraft] = useState(appSettings.natsCreds ?? ""); + const [messageDraft, setMessageDraft] = useState(""); + + useEffect(() => { + setNatsUrlDraft(appSettings.natsUrl ?? ""); + }, [appSettings.natsUrl]); + + useEffect(() => { + setNatsAuthModeDraft(appSettings.natsAuthMode); + }, [appSettings.natsAuthMode]); + + useEffect(() => { + setNatsUsernameDraft(appSettings.natsUsername ?? ""); + }, [appSettings.natsUsername]); + + useEffect(() => { + setNatsPasswordDraft(appSettings.natsPassword ?? ""); + }, [appSettings.natsPassword]); + + useEffect(() => { + setNatsCredsDraft(appSettings.natsCreds ?? ""); + }, [appSettings.natsCreds]); + + 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(), + natsAuthMode: natsAuthModeDraft, + natsUsername: natsUsernameDraft.trim() ? natsUsernameDraft.trim() : null, + natsPassword: natsPasswordDraft.length ? natsPasswordDraft : null, + natsCreds: natsCredsDraft.trim() ? natsCredsDraft.trim() : null, + }; + 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
+ + + + + {natsAuthModeDraft === "userpass" ? ( + <> + + + + ) : null} + + {natsAuthModeDraft === "creds" ? ( +