From 2fbee8ca780ef447e81e087209811208139f3382 Mon Sep 17 00:00:00 2001 From: lius-new Date: Wed, 12 Aug 2026 09:35:52 +0800 Subject: [PATCH] refactor: embed runtime into core application Move the runtime back into the main repository as the runtime crate, replace the external process bridge with in-process hosting, improve browser startup readiness and error reporting, and add the --skip-update startup option. --- .claude/settings.local.json | 26 - .../workflows/tauri-build-win-official.yml | 2 - .github/workflows/tauri-release-win.yml | 2 - .gitignore | 4 +- CLAUDE.md | 3 - CONTRIBUTING.md | 2 +- README.md | 2 - README.zh-CN.md | 2 - src-tauri/Cargo.lock | 18 + src-tauri/Cargo.toml | 1 + src-tauri/build.rs | 153 +--- src-tauri/config.example.toml | 1 - src-tauri/crates/runtime/Cargo.toml | 31 + src-tauri/crates/runtime/README.md | 12 + src-tauri/crates/runtime/src/app/api.rs | 70 ++ src-tauri/crates/runtime/src/app/context.rs | 32 + src-tauri/crates/runtime/src/app/error.rs | 58 ++ src-tauri/crates/runtime/src/app/events.rs | 43 + src-tauri/crates/runtime/src/app/host.rs | 438 ++++++++++ src-tauri/crates/runtime/src/app/mod.rs | 18 + src-tauri/crates/runtime/src/app/module.rs | 228 +++++ src-tauri/crates/runtime/src/app/state.rs | 125 +++ .../src/infrastructure/diagnostics/mod.rs | 47 ++ .../src/infrastructure/eventbus/connection.rs | 145 ++++ .../src/infrastructure/eventbus/error.rs | 67 ++ .../src/infrastructure/eventbus/global.rs | 75 ++ .../src/infrastructure/eventbus/manager.rs | 580 +++++++++++++ .../src/infrastructure/eventbus/message.rs | 233 ++++++ .../src/infrastructure/eventbus/mod.rs | 20 + .../src/infrastructure/eventbus/topics.rs | 60 ++ .../src/infrastructure/eventbus/transport.rs | 263 ++++++ .../runtime/src/infrastructure/ipc/error.rs | 58 ++ .../runtime/src/infrastructure/ipc/message.rs | 276 ++++++ .../runtime/src/infrastructure/ipc/mod.rs | 7 + .../runtime/src/infrastructure/ipc/topics.rs | 53 ++ .../crates/runtime/src/infrastructure/mod.rs | 3 + src-tauri/crates/runtime/src/lib.rs | 3 + .../crates/runtime/src/services/auth/mod.rs | 252 ++++++ .../crates/runtime/src/services/auth/types.rs | 17 + .../src/services/environment/kernel/cdp.rs | 116 +++ .../src/services/environment/kernel/job.rs | 132 +++ .../services/environment/kernel/launcher.rs | 784 ++++++++++++++++++ .../src/services/environment/kernel/mod.rs | 226 +++++ .../src/services/environment/kernel/types.rs | 168 ++++ .../runtime/src/services/environment/mod.rs | 196 +++++ .../src/services/environment/status.rs | 16 + .../services/environment/status_manager.rs | 57 ++ src-tauri/crates/runtime/src/services/mod.rs | 3 + .../crates/runtime/src/services/sync/mod.rs | 284 +++++++ .../crates/runtime/src/services/sync/types.rs | 28 + src-tauri/src/app/context.rs | 12 +- src-tauri/src/app/runtime.rs | 401 +++------ src-tauri/src/app/setup.rs | 3 +- src-tauri/src/app/splashscreen.rs | 142 ++-- src-tauri/src/core/config/types.rs | 1 - src-tauri/src/core/config/validator.rs | 14 - .../src/infrastructure/http/encryption/aes.rs | 22 +- .../environment/kernel/runtime_bridge.rs | 96 ++- .../services/environment/status_manager.rs | 11 + src-tauri/src/services/mod.rs | 1 - src-tauri/src/services/runtime_updater/mod.rs | 4 - .../src/services/runtime_updater/service.rs | 293 ------- .../src/services/runtime_updater/types.rs | 28 - .../src/services/updater/update_service.rs | 15 +- src-tauri/tauri.conf.fixed.json | 3 - src-tauri/tauri.conf.json | 3 - src-tauri/tauri.conf.window.download.json | 3 - 67 files changed, 5568 insertions(+), 924 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 CLAUDE.md create mode 100644 src-tauri/crates/runtime/Cargo.toml create mode 100644 src-tauri/crates/runtime/README.md create mode 100644 src-tauri/crates/runtime/src/app/api.rs create mode 100644 src-tauri/crates/runtime/src/app/context.rs create mode 100644 src-tauri/crates/runtime/src/app/error.rs create mode 100644 src-tauri/crates/runtime/src/app/events.rs create mode 100644 src-tauri/crates/runtime/src/app/host.rs create mode 100644 src-tauri/crates/runtime/src/app/mod.rs create mode 100644 src-tauri/crates/runtime/src/app/module.rs create mode 100644 src-tauri/crates/runtime/src/app/state.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/diagnostics/mod.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/connection.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/error.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/global.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/manager.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/message.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/mod.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/topics.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/eventbus/transport.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/ipc/error.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/ipc/message.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/ipc/mod.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/ipc/topics.rs create mode 100644 src-tauri/crates/runtime/src/infrastructure/mod.rs create mode 100644 src-tauri/crates/runtime/src/lib.rs create mode 100644 src-tauri/crates/runtime/src/services/auth/mod.rs create mode 100644 src-tauri/crates/runtime/src/services/auth/types.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/kernel/cdp.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/kernel/job.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/kernel/mod.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/kernel/types.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/mod.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/status.rs create mode 100644 src-tauri/crates/runtime/src/services/environment/status_manager.rs create mode 100644 src-tauri/crates/runtime/src/services/mod.rs create mode 100644 src-tauri/crates/runtime/src/services/sync/mod.rs create mode 100644 src-tauri/crates/runtime/src/services/sync/types.rs delete mode 100644 src-tauri/src/services/runtime_updater/mod.rs delete mode 100644 src-tauri/src/services/runtime_updater/service.rs delete mode 100644 src-tauri/src/services/runtime_updater/types.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 5e97c9dd..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(cargo check:*)", - "Bash(grep:*)", - "Bash(find:*)", - "Bash(git mv:*)", - "Bash(cargo build:*)", - "Bash(wc:*)", - "Bash(ls:*)", - "Bash(git ls-tree:*)", - "Bash(echo:*)", - "Bash(git log:*)", - "Bash(cargo clippy:*)", - "Bash(done)", - "Bash(thinks/architecture-responsibility-review.md:*)", - "Bash(test:*)", - "Bash(git rm:*)", - "mcp__simprint__simprint_list_environments", - "mcp__simprint__simprint_start_environment", - "mcp__simprint__simprint_stop_environment" - ] - } -} diff --git a/.github/workflows/tauri-build-win-official.yml b/.github/workflows/tauri-build-win-official.yml index 0c8f4171..bb60f810 100644 --- a/.github/workflows/tauri-build-win-official.yml +++ b/.github/workflows/tauri-build-win-official.yml @@ -29,7 +29,6 @@ jobs: APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_RUNTIME_LATEST_JSON_URL: ${{ secrets.APP_RUNTIME_LATEST_JSON_URL }} APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} @@ -136,7 +135,6 @@ jobs: Set-ConfigValue '^(secret_key\s*=\s*)".*"$' "${{ env.APP_SERVER_SECRET_KEY }}" Set-ConfigValue '^(check_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_CHECK_URL }}" Set-ConfigValue '^(latest_json_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_LATEST_JSON_URL }}" - Set-ConfigValue '^(runtime_latest_json_url\s*=\s*)".*"$' "${{ env.APP_RUNTIME_LATEST_JSON_URL }}" Set-ConfigValue '^(updater_temp_dir\s*=\s*)".*"$' "${{ env.APP_UPDATER_TEMP_DIR }}" Set-ConfigValue '^(downlaod_url\s*=\s*)".*"$' "${{ env.APP_WEBVIEW_DOWNLOAD_URL }}" diff --git a/.github/workflows/tauri-release-win.yml b/.github/workflows/tauri-release-win.yml index 40a5ffc6..dc001af8 100644 --- a/.github/workflows/tauri-release-win.yml +++ b/.github/workflows/tauri-release-win.yml @@ -49,7 +49,6 @@ jobs: APP_SERVER_SECRET_KEY: ${{ secrets.APP_SERVER_SECRET_KEY }} APP_UPDATER_CHECK_URL: ${{ secrets.APP_UPDATER_CHECK_URL }} APP_UPDATER_LATEST_JSON_URL: ${{ secrets.APP_UPDATER_LATEST_JSON_URL }} - APP_RUNTIME_LATEST_JSON_URL: ${{ secrets.APP_RUNTIME_LATEST_JSON_URL }} APP_UPDATER_TEMP_DIR: ${{ secrets.APP_UPDATER_TEMP_DIR }} APP_WEBVIEW_DOWNLOAD_URL: ${{ secrets.APP_WEBVIEW_DOWNLOAD_URL }} run: | @@ -82,7 +81,6 @@ jobs: Set-ConfigValue '^(secret_key\s*=\s*)".*"$' "${{ env.APP_SERVER_SECRET_KEY }}" Set-ConfigValue '^(check_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_CHECK_URL }}" Set-ConfigValue '^(latest_json_url\s*=\s*)".*"$' "${{ env.APP_UPDATER_LATEST_JSON_URL }}" - Set-ConfigValue '^(runtime_latest_json_url\s*=\s*)".*"$' "${{ env.APP_RUNTIME_LATEST_JSON_URL }}" Set-ConfigValue '^(updater_temp_dir\s*=\s*)".*"$' "${{ env.APP_UPDATER_TEMP_DIR }}" Set-ConfigValue '^(downlaod_url\s*=\s*)".*"$' "${{ env.APP_WEBVIEW_DOWNLOAD_URL }}" diff --git a/.gitignore b/.gitignore index 8dfd27ee..935ecc39 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,9 @@ dist-ssr src/core/plugin/loader/plugin-imports.generated.ts # tauri generated files -src-tauri/target +src-tauri/target +src-tauri/crates/*/target/ +src-tauri/crates/*/Cargo.lock # packages packages diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f1b2ab51..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -\## 操作建议 - -* 不可以主动提交代码 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0ed9fbc..cb3d4fd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,7 @@ Current high-value contribution areas include: - Frontend UX polish and workflow consistency - Tests, regression coverage, and release verification -Broader collaboration around additional core components is planned over time, including the runtime process (`simprint-runtime`) and the browser-kernel layer (`simprint-browser-kernel`). For now, contributions to this public repository are still highly valuable and help make the overall ecosystem easier to maintain. +The environment runtime is maintained in this repository under `src-tauri/crates/runtime`; the browser-kernel layer (`simprint-browser-kernel`) remains a separate component. Changes to environment lifecycle behavior should include tests in the embedded runtime crate whenever possible. ## Communication diff --git a/README.md b/README.md index 9ea1ae55..b63b08aa 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,6 @@ Current high-value contribution areas include: Issues and pull requests are welcome. If you are interested in contributing on a longer horizon, please open an issue or discussion to introduce yourself and mention the areas you want to help maintain. -Additional core components are also being prepared for broader collaboration over time, including the runtime process (`simprint-runtime`) and the browser-kernel layer (`simprint-browser-kernel`). The long-term goal is to build a maintainable open ecosystem around Simprint rather than keep contribution limited to the client surface. - ## Friend Links - [LINUX DO - 新的理想型社区](https://linux.do/) diff --git a/README.zh-CN.md b/README.zh-CN.md index a20c29f0..e31118d0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,8 +117,6 @@ Simprint 目前正处于持续推进的开源迁移阶段,我们希望逐步 欢迎提交 Issue 和 Pull Request。如果你希望长期参与维护,也欢迎通过 Issue 或 Discussion 简单介绍自己,并说明你希望参与的方向。 -包括运行时进程(`simprint-runtime`)和浏览器内核层(`simprint-browser-kernel`)在内的更多核心组件,后续也会逐步为更广泛的协作做准备。我们的长期目标不是只开放客户端表层代码,而是逐步建设一个可持续维护的 Simprint 开源生态。 - ## Friend Links - [LINUX DO - 新的理想型社区](https://linux.do/) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7221bc87..682c846b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4679,6 +4679,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "runtime" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "log", + "rand 0.8.5", + "reqwest", + "rmp-serde", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "windows 0.61.3", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -5233,6 +5250,7 @@ dependencies = [ "rmcp", "rmp-serde", "rsa", + "runtime", "schemars 1.2.0", "serde", "serde_bytes", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a32b6bde..88fe3b66 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -86,6 +86,7 @@ directories = "6.0.0" rmp-serde = "1.3" # MessagePack 序列化 serde_bytes = "0.11" # 字节数组序列化 indexmap = { version = "2.12.0", features = ["serde"] } +runtime = { path = "crates/runtime" } hkdf = "0.12" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index c3e43202..c85f972b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -17,22 +17,19 @@ mod crypto; // ============================================================================= fn main() { - // 1. 准备 simprint-runtime 资源 - runtime_assets::ensure_simprint_runtime_downloaded(); - - // 2. 仅在生产环境下下载 / 准备 webview-fixed 目录中的资源 + // 1. 仅在生产环境下下载 / 准备 webview-fixed 目录中的资源 #[cfg(feature = "production")] { webview_assets::ensure_webview_fixed_downloaded(); } - // 3. 构建 Tauri 应用(处理 Windows manifest / 权限等) + // 2. 构建 Tauri 应用(处理 Windows manifest / 权限等) tauri_build_pipeline::build_tauri(); - // 4. 为前端构建写入环境标记文件(.build-env) + // 3. 为前端构建写入环境标记文件(.build-env) frontend_env::prepare_frontend_build_env(); - // 5. 读取明文配置并生成加密后的二进制配置文件 + // 4. 读取明文配置并生成加密后的二进制配置文件 config_encrypt::generate_encrypted_config(); } @@ -71,147 +68,7 @@ pub(crate) fn current_config_file_name() -> &'static str { } // ============================================================================= -// 模块一:Runtime 资源下载 -// ============================================================================= - -mod runtime_assets { - use super::*; - use sha2::{Digest, Sha256}; - - const RUNTIME_RESOURCE_PATH: &str = "resources/simprint-runtime.exe"; - - #[derive(Deserialize)] - struct UpdaterConfig { - runtime_latest_json_url: String, - } - - #[derive(Deserialize)] - struct RuntimeLatestRelease { - platforms: std::collections::HashMap, - } - - #[derive(Deserialize)] - struct RuntimeLatestReleasePlatform { - url: String, - sha256: String, - } - - pub fn ensure_simprint_runtime_downloaded() { - println!("cargo:rerun-if-changed={}", RUNTIME_RESOURCE_PATH); - - let target_path = Path::new(RUNTIME_RESOURCE_PATH); - if target_path.exists() { - return; - } - - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).unwrap_or_else(|error| { - panic!( - "[BUILD ERROR] Failed to create runtime resources directory '{}': {}", - parent.display(), - error - ); - }); - } - - let latest_json_url = detect_runtime_latest_json_url().unwrap_or_else(|| { - panic!( - "[BUILD ERROR] Failed to detect runtime latest.json URL from config file '{}'.", - super::current_config_file_name() - ); - }); - - if let Err(error) = download_runtime_artifact(&latest_json_url, target_path) { - panic!("failed to download simprint-runtime: {error}"); - } - } - - fn detect_runtime_latest_json_url() -> Option { - let config_file_name = super::current_config_file_name(); - - let config = Config::builder() - .add_source(config::File::with_name(config_file_name)) - .build() - .map_err(|e| { - eprintln!( - "[BUILD ERROR] Failed to load config file '{}': {}", - config_file_name, e - ); - e - }) - .ok()?; - - let updater_config: UpdaterConfig = config - .get("updater") - .map_err(|e| { - eprintln!( - "[BUILD ERROR] Failed to parse [updater] section in '{}': {}", - config_file_name, e - ); - e - }) - .ok()?; - - Some(updater_config.runtime_latest_json_url) - } - - fn download_runtime_artifact( - latest_json_url: &str, - target_path: &Path, - ) -> Result<(), Box> { - println!( - "cargo:warning=Downloading simprint-runtime metadata from {}", - latest_json_url - ); - - let client = reqwest::blocking::Client::builder().build()?; - let manifest = client - .get(latest_json_url) - .send()? - .error_for_status()? - .json::()?; - - let target_triple = env::var("TARGET")?; - let platform = manifest.platforms.get(&target_triple).ok_or_else(|| { - format!( - "runtime latest.json does not contain target platform '{}'", - target_triple - ) - })?; - - if platform.url.trim().is_empty() { - return Err("runtime latest.json url is empty".into()); - } - - println!( - "cargo:warning=Downloading simprint-runtime binary from {}", - platform.url - ); - - let bytes = client.get(&platform.url).send()?.error_for_status()?.bytes()?; - - let actual_sha256 = sha256_hex(&bytes); - if !actual_sha256.eq_ignore_ascii_case(&platform.sha256) { - return Err(format!( - "runtime sha256 mismatch: expected {}, actual {}", - platform.sha256, actual_sha256 - ) - .into()); - } - - fs::write(target_path, &bytes)?; - Ok(()) - } - - fn sha256_hex(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - format!("{:x}", hasher.finalize()) - } -} - -// ============================================================================= -// 模块二:Webview 资源下载与解压 +// 模块一:Webview 资源下载与解压 // ============================================================================= mod webview_assets { diff --git a/src-tauri/config.example.toml b/src-tauri/config.example.toml index 2b0dba82..e64ddc95 100644 --- a/src-tauri/config.example.toml +++ b/src-tauri/config.example.toml @@ -6,7 +6,6 @@ secret_key = "Nuexz9Y2hRc5Z6HK7Atb" [updater] check_url = "https://update.simprint.app/api/v1/versions/check" latest_json_url = "https://pub-39307a5e69c74324855a762027cbf9bf.r2.dev/latest.json" -runtime_latest_json_url = "https://pub-d9427ecc5980437ba1445fc79ea593bd.r2.dev/simprint-runtime/latest.json" updater_temp_dir = "updates" [webview] diff --git a/src-tauri/crates/runtime/Cargo.toml b/src-tauri/crates/runtime/Cargo.toml new file mode 100644 index 00000000..8412b6ab --- /dev/null +++ b/src-tauri/crates/runtime/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "runtime" +version = "0.1.0" +edition = "2024" +publish = false +license = "AGPL-3.0-only" +autobins = false + +[lib] +name = "runtime" +path = "src/lib.rs" + +[dependencies] +async-trait = "0.1" +bytes = "1" +log = "0.4" +rand = "0.8" +rmp-serde = "1.3" +reqwest = { version = "0.12.22", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2.0.12" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "sync", "time", "net", "process"] } + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.61.1", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } diff --git a/src-tauri/crates/runtime/README.md b/src-tauri/crates/runtime/README.md new file mode 100644 index 00000000..4925874d --- /dev/null +++ b/src-tauri/crates/runtime/README.md @@ -0,0 +1,12 @@ +# Embedded environment runtime + +This crate owns browser process supervision, the browser EventBus, environment status, CDP +endpoints, authentication propagation, and synchronized-input routing. + +It intentionally has no binary target. The Tauri application creates `RuntimeHost` in-process and +forwards runtime events to the existing frontend event names. Chrome remains a separate supervised +process and communicates with this crate through the per-environment EventBus transport. + +A browser launch is successful only after the browser process connects and completes the EventBus +handshake. Process exit, transport failure, or the startup deadline transitions the environment to +`error` and emits `environment.launch_failed`. diff --git a/src-tauri/crates/runtime/src/app/api.rs b/src-tauri/crates/runtime/src/app/api.rs new file mode 100644 index 00000000..3cb35878 --- /dev/null +++ b/src-tauri/crates/runtime/src/app/api.rs @@ -0,0 +1,70 @@ +use super::context::RuntimeContextInput; +use super::state::{HealthSnapshot, RuntimePhase, RuntimeStateSnapshot}; +use crate::services::auth::types::AuthCommandResponse; +use crate::services::environment::kernel::types::EnvironmentCommandResponse; +use crate::services::sync::types::SyncCommandResponse; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct EmptyPayload {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandshakeRequest { + pub protocol_version: u8, + pub client_name: String, + pub client_version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandshakeResponse { + pub protocol_version: u8, + pub runtime_version: String, + pub runtime_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeContextRequest { + pub context: RuntimeContextInput, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DestroyContextRequest { + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PingResponse { + pub runtime_id: String, + pub phase: RuntimePhase, + pub uptime_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateResponse { + pub state: RuntimeStateSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthResponse { + pub health: HealthSnapshot, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentResponse { + pub result: EnvironmentCommandResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthResponse { + pub result: AuthCommandResponse, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SyncResponse { + pub result: SyncCommandResponse, +} diff --git a/src-tauri/crates/runtime/src/app/context.rs b/src-tauri/crates/runtime/src/app/context.rs new file mode 100644 index 00000000..97032ae7 --- /dev/null +++ b/src-tauri/crates/runtime/src/app/context.rs @@ -0,0 +1,32 @@ +use crate::infrastructure::diagnostics::unix_now_ms; +use crate::infrastructure::eventbus::AuthInfo; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RuntimeContextInput { + pub user_id: Option, + pub workspace_id: Option, + #[serde(default)] + pub auth_info: Option, + #[serde(default)] + pub attributes: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeContext { + pub context_id: String, + pub initialized_at_unix_ms: u64, + pub input: RuntimeContextInput, +} + +impl RuntimeContext { + pub fn new(sequence: u64, input: RuntimeContextInput) -> Self { + Self { + context_id: format!("context-{}", sequence), + initialized_at_unix_ms: unix_now_ms(), + input, + } + } +} diff --git a/src-tauri/crates/runtime/src/app/error.rs b/src-tauri/crates/runtime/src/app/error.rs new file mode 100644 index 00000000..33485930 --- /dev/null +++ b/src-tauri/crates/runtime/src/app/error.rs @@ -0,0 +1,58 @@ +use crate::infrastructure::ipc::{ErrorCode, IpcError}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum RuntimeError { + #[error("invalid runtime state: {0}")] + InvalidState(String), + + #[error("runtime context already initialized")] + AlreadyInitialized, + + #[error("runtime context is not initialized")] + NotInitialized, + + #[error("module '{module}' failed during '{action}': {message}")] + ModuleLifecycle { + module: &'static str, + action: &'static str, + message: String, + }, + + #[error("serialization error: {0}")] + Serialization(String), + + #[error("ipc error: {0}")] + Ipc(#[from] IpcError), + + #[error("eventbus error: {0}")] + EventBus(#[from] crate::infrastructure::eventbus::EventBusError), + + #[error("internal error: {0}")] + Internal(String), +} + +impl RuntimeError { + pub fn code(&self) -> ErrorCode { + match self { + Self::InvalidState(_) => ErrorCode::InvalidState, + Self::AlreadyInitialized => ErrorCode::AlreadyInitialized, + Self::NotInitialized => ErrorCode::NotInitialized, + Self::ModuleLifecycle { .. } => ErrorCode::ModuleFailed, + Self::Serialization(_) => ErrorCode::InternalError, + Self::Ipc(IpcError::Connection(_)) => ErrorCode::ConnectionFailed, + Self::Ipc(IpcError::ConnectionClosed) => ErrorCode::ConnectionClosed, + Self::Ipc(IpcError::SendFailed(_)) => ErrorCode::SendFailed, + Self::Ipc(IpcError::ReceiveFailed(_)) => ErrorCode::ConnectionClosed, + Self::Ipc(IpcError::Encode(_)) => ErrorCode::InternalError, + Self::Ipc(IpcError::Decode(_)) => ErrorCode::DecodeFailed, + Self::Ipc(IpcError::InvalidMessage(_)) => ErrorCode::InvalidMessage, + Self::Ipc(IpcError::Serialization(_)) => ErrorCode::InternalError, + Self::Ipc(IpcError::Io(_)) => ErrorCode::InternalError, + Self::EventBus(_) => ErrorCode::InternalError, + Self::Internal(_) => ErrorCode::InternalError, + } + } +} + +pub type Result = std::result::Result; diff --git a/src-tauri/crates/runtime/src/app/events.rs b/src-tauri/crates/runtime/src/app/events.rs new file mode 100644 index 00000000..fecdb016 --- /dev/null +++ b/src-tauri/crates/runtime/src/app/events.rs @@ -0,0 +1,43 @@ +use super::error::{Result, RuntimeError}; +use crate::infrastructure::diagnostics::unix_now_ms; +use serde::Serialize; +use serde_json::Value; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, Serialize)] +pub struct RuntimeEventEnvelope { + pub name: String, + pub emitted_at_unix_ms: u64, + pub payload: Value, +} + +#[derive(Clone)] +pub struct EventPublisher { + tx: mpsc::UnboundedSender, +} + +impl EventPublisher { + pub fn emit_value(&self, name: impl Into, payload: Value) -> Result<()> { + self.tx + .send(RuntimeEventEnvelope { + name: name.into(), + emitted_at_unix_ms: unix_now_ms(), + payload, + }) + .map_err(|error| RuntimeError::Internal(format!("event channel closed: {}", error))) + } + + pub fn emit(&self, name: impl Into, payload: &T) -> Result<()> { + let value = serde_json::to_value(payload) + .map_err(|error| RuntimeError::Serialization(error.to_string()))?; + self.emit_value(name, value) + } +} + +pub fn event_channel() -> ( + EventPublisher, + mpsc::UnboundedReceiver, +) { + let (tx, rx) = mpsc::unbounded_channel(); + (EventPublisher { tx }, rx) +} diff --git a/src-tauri/crates/runtime/src/app/host.rs b/src-tauri/crates/runtime/src/app/host.rs new file mode 100644 index 00000000..4b61529b --- /dev/null +++ b/src-tauri/crates/runtime/src/app/host.rs @@ -0,0 +1,438 @@ +use super::api::{ + AuthResponse, DestroyContextRequest, EmptyPayload, EnvironmentResponse, ErrorResponse, + HandshakeRequest, HandshakeResponse, HealthResponse, InitializeContextRequest, PingResponse, + StateResponse, SyncResponse, +}; +use super::context::RuntimeContext; +use super::error::{Result, RuntimeError}; +use super::events::EventPublisher; +use super::module::{ModuleContext, ModuleOrchestrator}; +use super::state::{HealthSnapshot, RuntimePhase, RuntimeStateStore}; +use crate::infrastructure::diagnostics::{log_error, log_info}; +use crate::infrastructure::ipc::{Message, PROTOCOL_VERSION, Topic}; +use crate::services::auth::types::AuthCommandRequest; +use crate::services::auth::{AuthRuntime, AuthStateStore}; +use crate::services::environment::EnvironmentRuntimeModule; +use crate::services::environment::kernel::types::EnvironmentCommandRequest; +use crate::services::sync::SyncRuntimeModule; +use crate::services::sync::types::SyncCommandRequest; +use serde_json::json; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; +use tokio::sync::RwLock; + +pub enum DispatchControl { + Continue, + Shutdown, +} + +pub struct DispatchResult { + pub response: Message, + pub control: DispatchControl, +} + +pub struct RuntimeHost { + state: RuntimeStateStore, + context: RwLock>, + context_sequence: AtomicU64, + modules: ModuleOrchestrator, + events: EventPublisher, + auth: Arc, + environment: Arc, + sync: Arc, +} + +impl RuntimeHost { + pub fn new( + runtime_id: impl Into, + runtime_version: impl Into, + modules: ModuleOrchestrator, + events: EventPublisher, + auth: Arc, + environment: Arc, + sync: Arc, + ) -> Self { + Self { + state: RuntimeStateStore::new(runtime_id, runtime_version), + context: RwLock::new(None), + context_sequence: AtomicU64::new(1), + modules, + events, + auth, + environment, + sync, + } + } + + pub fn default(events: EventPublisher) -> Arc { + let auth_state = Arc::new(AuthStateStore::new()); + let auth = Arc::new(AuthRuntime::new(auth_state.clone())); + let environment = Arc::new(EnvironmentRuntimeModule::new(auth_state)); + let sync = Arc::new(SyncRuntimeModule::new()); + let modules = ModuleOrchestrator::new() + .register(auth.clone()) + .register(environment.clone()) + .register(sync.clone()); + + Arc::new(Self::new( + "runtime", + env!("CARGO_PKG_VERSION"), + modules, + events, + auth, + environment, + sync, + )) + } + + pub async fn start(&self) -> Result<()> { + if self.state.phase().await != RuntimePhase::Booting { + return Err(RuntimeError::InvalidState( + "runtime host can only start from booting phase".into(), + )); + } + + self.modules + .start(ModuleContext { + events: self.events.clone(), + }) + .await?; + self.state.clear_error().await; + self.state.transition(RuntimePhase::Uninitialized).await; + self.events.emit("runtime.started", &json!({}))?; + log_info("runtime", "runtime host started"); + Ok(()) + } + + pub async fn handle_request(&self, request: Message) -> Result { + match request.topic { + Topic::Handshake => self.handle_handshake(request).await, + Topic::Ping => self.handle_ping(request).await, + Topic::QueryState => self.handle_query_state(request).await, + Topic::QueryHealth => self.handle_query_health(request).await, + Topic::InitializeContext => self.handle_initialize_context(request).await, + Topic::DestroyContext => self.handle_destroy_context(request).await, + Topic::Shutdown => self.handle_shutdown(request).await, + Topic::EnvironmentCommand => self.handle_environment_command(request).await, + Topic::SyncCommand => self.handle_sync_command(request).await, + Topic::AuthCommand => self.handle_auth_command(request).await, + Topic::RuntimeEvent => Err(RuntimeError::InvalidState( + "runtime does not accept inbound runtime_event frames".into(), + )), + Topic::Unknown(value) => Err(RuntimeError::InvalidState(format!( + "unknown topic: {}", + value + ))), + } + } + + pub async fn shutdown_due_to_disconnect(&self) -> Result<()> { + log_info("runtime", "peer disconnected; shutting down runtime"); + self.shutdown_runtime().await + } + + async fn handle_handshake(&self, request: Message) -> Result { + let _payload: HandshakeRequest = request.payload()?; + let response = HandshakeResponse { + protocol_version: PROTOCOL_VERSION, + runtime_version: self.state.runtime_version().to_string(), + runtime_id: self.state.runtime_id().to_string(), + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::Handshake, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_ping(&self, request: Message) -> Result { + let _: EmptyPayload = request.payload()?; + let snapshot = self.state.snapshot(self.modules.len()).await; + let response = PingResponse { + runtime_id: snapshot.runtime_id.clone(), + phase: snapshot.phase, + uptime_ms: snapshot.uptime_ms, + }; + Ok(DispatchResult { + response: Message::success_response_payload(request.msg_id, Topic::Ping, &response)?, + control: DispatchControl::Continue, + }) + } + + async fn handle_query_state(&self, request: Message) -> Result { + let _: EmptyPayload = request.payload()?; + let response = StateResponse { + state: self.state.snapshot(self.modules.len()).await, + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::QueryState, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_query_health(&self, request: Message) -> Result { + let _: EmptyPayload = request.payload()?; + let modules = self.modules.health_snapshot().await; + let response = HealthResponse { + health: HealthSnapshot { + runtime: self.state.snapshot(self.modules.len()).await, + modules, + }, + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::QueryHealth, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_initialize_context(&self, request: Message) -> Result { + let payload: InitializeContextRequest = request.payload()?; + let state_phase = self.state.phase().await; + if state_phase == RuntimePhase::Ready { + return Err(RuntimeError::AlreadyInitialized); + } + if state_phase != RuntimePhase::Uninitialized { + return Err(RuntimeError::InvalidState(format!( + "cannot initialize context from phase {:?}", + state_phase + ))); + } + + self.state.transition(RuntimePhase::Initializing).await; + self.state.clear_error().await; + + let sequence = self.context_sequence.fetch_add(1, Ordering::SeqCst); + let context = RuntimeContext::new(sequence, payload.context); + + if let Err(error) = self.modules.initialize_context(context.clone()).await { + self.state.record_error(error.to_string()).await; + self.state.transition(RuntimePhase::Uninitialized).await; + return Err(error); + } + + { + let mut guard = self.context.write().await; + *guard = Some(context.clone()); + } + self.state.attach_context(context.context_id.clone()).await; + self.state.transition(RuntimePhase::Ready).await; + self.events.emit( + "runtime.context_initialized", + &json!({ "context_id": context.context_id }), + )?; + + let response = StateResponse { + state: self.state.snapshot(self.modules.len()).await, + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::InitializeContext, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_destroy_context(&self, request: Message) -> Result { + let _payload: DestroyContextRequest = request.payload()?; + self.destroy_context().await?; + + let response = StateResponse { + state: self.state.snapshot(self.modules.len()).await, + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::DestroyContext, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_shutdown(&self, request: Message) -> Result { + let _: EmptyPayload = request.payload()?; + self.shutdown_runtime().await?; + + let response = StateResponse { + state: self.state.snapshot(self.modules.len()).await, + }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::Shutdown, + &response, + )?, + control: DispatchControl::Shutdown, + }) + } + + async fn handle_environment_command(&self, request: Message) -> Result { + let command: EnvironmentCommandRequest = request.payload()?; + let result = self.environment.execute_command(command).await?; + let response = EnvironmentResponse { result }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::EnvironmentCommand, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_sync_command(&self, request: Message) -> Result { + let command: SyncCommandRequest = request.payload()?; + let result = self.sync.execute_command(command).await?; + let response = SyncResponse { result }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::SyncCommand, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn handle_auth_command(&self, request: Message) -> Result { + let command: AuthCommandRequest = request.payload()?; + let result = self.auth.execute_command(command).await?; + let response = AuthResponse { result }; + Ok(DispatchResult { + response: Message::success_response_payload( + request.msg_id, + Topic::AuthCommand, + &response, + )?, + control: DispatchControl::Continue, + }) + } + + async fn destroy_context(&self) -> Result<()> { + let context_id = { + let guard = self.context.read().await; + guard + .as_ref() + .map(|context| context.context_id.clone()) + .ok_or(RuntimeError::NotInitialized)? + }; + + self.state.transition(RuntimePhase::Destroying).await; + if let Err(error) = self.modules.destroy_context().await { + self.state.record_error(error.to_string()).await; + self.state.transition(RuntimePhase::Ready).await; + return Err(error); + } + + { + let mut guard = self.context.write().await; + *guard = None; + } + self.state.clear_context().await; + self.state.transition(RuntimePhase::Uninitialized).await; + self.events.emit( + "runtime.context_destroyed", + &json!({ "context_id": context_id }), + )?; + Ok(()) + } + + async fn shutdown_runtime(&self) -> Result<()> { + let phase = self.state.phase().await; + if matches!(phase, RuntimePhase::Stopped | RuntimePhase::ShuttingDown) { + return Ok(()); + } + + self.state.transition(RuntimePhase::ShuttingDown).await; + + if self.context.read().await.is_some() { + if let Err(error) = self.modules.destroy_context().await { + log_error( + "runtime", + format!("context destroy during shutdown failed: {}", error), + ); + self.state.record_error(error.to_string()).await; + } + let mut guard = self.context.write().await; + *guard = None; + self.state.clear_context().await; + } + + if let Err(error) = self.modules.shutdown().await { + self.state.record_error(error.to_string()).await; + self.state.transition(RuntimePhase::Failed).await; + return Err(error); + } + + self.state.transition(RuntimePhase::Stopped).await; + self.events.emit("runtime.stopped", &json!({}))?; + log_info("runtime", "runtime host stopped"); + Ok(()) + } + + pub fn error_response_for(&self, request: &Message, error: &RuntimeError) -> Result { + Message::error_response_payload( + request.msg_id, + request.topic, + error.code(), + &ErrorResponse { + message: error.to_string(), + }, + ) + .map_err(RuntimeError::from) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{EmptyPayload, InitializeContextRequest, event_channel}; + use crate::infrastructure::ipc::{Message, Topic}; + + #[tokio::test] + async fn host_supports_an_in_process_lifecycle() { + let (events, _event_rx) = event_channel(); + let auth_state = Arc::new(AuthStateStore::new()); + let host = Arc::new(RuntimeHost::new( + "test-runtime", + "0.0.0-test", + ModuleOrchestrator::new(), + events, + Arc::new(AuthRuntime::new(auth_state.clone())), + Arc::new(EnvironmentRuntimeModule::new(auth_state)), + Arc::new(SyncRuntimeModule::new()), + )); + host.start().await.unwrap(); + + let initialize = Message::request_payload( + Topic::InitializeContext, + &InitializeContextRequest { + context: Default::default(), + }, + ) + .unwrap(); + let initialized = host.handle_request(initialize).await.unwrap(); + let state: StateResponse = initialized.response.payload().unwrap(); + assert_eq!(state.state.phase, RuntimePhase::Ready); + + let shutdown = Message::request_payload(Topic::Shutdown, &EmptyPayload::default()).unwrap(); + let stopped = host.handle_request(shutdown).await.unwrap(); + let state: StateResponse = stopped.response.payload().unwrap(); + assert_eq!(state.state.phase, RuntimePhase::Stopped); + } +} diff --git a/src-tauri/crates/runtime/src/app/mod.rs b/src-tauri/crates/runtime/src/app/mod.rs new file mode 100644 index 00000000..8a4134be --- /dev/null +++ b/src-tauri/crates/runtime/src/app/mod.rs @@ -0,0 +1,18 @@ +mod api; +mod context; +mod error; +mod events; +mod host; +mod module; +mod state; + +pub use api::{ + AuthResponse, DestroyContextRequest, EmptyPayload, EnvironmentResponse, ErrorResponse, + HandshakeRequest, HandshakeResponse, InitializeContextRequest, PingResponse, SyncResponse, +}; +pub use context::{RuntimeContext, RuntimeContextInput}; +pub use error::{Result, RuntimeError}; +pub use events::{EventPublisher, RuntimeEventEnvelope, event_channel}; +pub use host::RuntimeHost; +pub use module::{ModuleContext, ModuleOrchestrator, RuntimeModule}; +pub use state::{HealthSnapshot, ModuleHealthSnapshot, RuntimePhase, RuntimeStateSnapshot}; diff --git a/src-tauri/crates/runtime/src/app/module.rs b/src-tauri/crates/runtime/src/app/module.rs new file mode 100644 index 00000000..15ac6c90 --- /dev/null +++ b/src-tauri/crates/runtime/src/app/module.rs @@ -0,0 +1,228 @@ +use super::context::RuntimeContext; +use super::error::{Result, RuntimeError}; +use super::events::EventPublisher; +use super::state::ModuleHealthSnapshot; +use async_trait::async_trait; +use std::sync::Arc; + +#[derive(Clone)] +pub struct ModuleContext { + pub events: EventPublisher, +} + +#[async_trait] +pub trait RuntimeModule: Send + Sync { + fn name(&self) -> &'static str; + + async fn on_runtime_start(&self, context: ModuleContext) -> Result<()>; + + async fn on_context_initialize(&self, context: RuntimeContext) -> Result<()>; + + async fn on_context_destroy(&self) -> Result<()>; + + async fn on_runtime_shutdown(&self) -> Result<()>; + + async fn health_snapshot(&self) -> ModuleHealthSnapshot; +} + +pub struct ModuleOrchestrator { + modules: Vec>, +} + +impl ModuleOrchestrator { + pub fn new() -> Self { + Self { + modules: Vec::new(), + } + } + + pub fn register(mut self, module: Arc) -> Self { + self.modules.push(module); + self + } + + pub fn len(&self) -> usize { + self.modules.len() + } + + pub async fn start(&self, context: ModuleContext) -> Result<()> { + let mut started: Vec> = Vec::new(); + + for module in &self.modules { + if let Err(error) = module.on_runtime_start(context.clone()).await { + for rollback in started.into_iter().rev() { + let _ = rollback.on_runtime_shutdown().await; + } + return Err(wrap_module_error(module.name(), "runtime_start", error)); + } + started.push(module.clone()); + } + + Ok(()) + } + + pub async fn initialize_context(&self, context: RuntimeContext) -> Result<()> { + let mut initialized: Vec> = Vec::new(); + + for module in &self.modules { + if let Err(error) = module.on_context_initialize(context.clone()).await { + for rollback in initialized.into_iter().rev() { + let _ = rollback.on_context_destroy().await; + } + return Err(wrap_module_error( + module.name(), + "context_initialize", + error, + )); + } + initialized.push(module.clone()); + } + + Ok(()) + } + + pub async fn destroy_context(&self) -> Result<()> { + let mut first_error = None; + + for module in self.modules.iter().rev() { + if let Err(error) = module.on_context_destroy().await { + if first_error.is_none() { + first_error = Some(wrap_module_error(module.name(), "context_destroy", error)); + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + pub async fn shutdown(&self) -> Result<()> { + let mut first_error = None; + + for module in self.modules.iter().rev() { + if let Err(error) = module.on_runtime_shutdown().await { + if first_error.is_none() { + first_error = Some(wrap_module_error(module.name(), "runtime_shutdown", error)); + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + pub async fn health_snapshot(&self) -> Vec { + let mut snapshots = Vec::with_capacity(self.modules.len()); + for module in &self.modules { + snapshots.push(module.health_snapshot().await); + } + snapshots + } +} + +fn wrap_module_error( + module: &'static str, + action: &'static str, + error: RuntimeError, +) -> RuntimeError { + RuntimeError::ModuleLifecycle { + module, + action, + message: error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::event_channel; + use crate::app::{RuntimeContextInput, RuntimeError}; + use serde_json::json; + use tokio::sync::Mutex; + + struct RecorderModule { + name: &'static str, + log: Arc>>, + fail_on_init: bool, + } + + #[async_trait] + impl RuntimeModule for RecorderModule { + fn name(&self) -> &'static str { + self.name + } + + async fn on_runtime_start(&self, _context: ModuleContext) -> Result<()> { + self.log.lock().await.push(format!("{}.start", self.name)); + Ok(()) + } + + async fn on_context_initialize(&self, _context: RuntimeContext) -> Result<()> { + self.log.lock().await.push(format!("{}.init", self.name)); + if self.fail_on_init { + return Err(RuntimeError::Internal("boom".into())); + } + Ok(()) + } + + async fn on_context_destroy(&self) -> Result<()> { + self.log.lock().await.push(format!("{}.destroy", self.name)); + Ok(()) + } + + async fn on_runtime_shutdown(&self) -> Result<()> { + self.log.lock().await.push(format!("{}.shutdown", self.name)); + Ok(()) + } + + async fn health_snapshot(&self) -> ModuleHealthSnapshot { + ModuleHealthSnapshot { + name: self.name.into(), + phase: "ready".into(), + healthy: true, + detail: json!({}), + } + } + } + + #[tokio::test] + async fn initialize_rollback_runs_in_reverse_order() { + let log = Arc::new(Mutex::new(Vec::new())); + let (events, _) = event_channel(); + + let orchestrator = ModuleOrchestrator::new() + .register(Arc::new(RecorderModule { + name: "env", + log: log.clone(), + fail_on_init: false, + })) + .register(Arc::new(RecorderModule { + name: "sync", + log: log.clone(), + fail_on_init: true, + })); + + orchestrator.start(ModuleContext { events }).await.unwrap(); + + let result = orchestrator + .initialize_context(RuntimeContext::new(1, RuntimeContextInput::default())) + .await; + assert!(result.is_err()); + + let log = log.lock().await.clone(); + assert_eq!( + log, + vec![ + "env.start", + "sync.start", + "env.init", + "sync.init", + "env.destroy", + ] + ); + } +} diff --git a/src-tauri/crates/runtime/src/app/state.rs b/src-tauri/crates/runtime/src/app/state.rs new file mode 100644 index 00000000..1c49591b --- /dev/null +++ b/src-tauri/crates/runtime/src/app/state.rs @@ -0,0 +1,125 @@ +use crate::infrastructure::diagnostics::unix_now_ms; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Instant; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimePhase { + Booting, + Uninitialized, + Initializing, + Ready, + Destroying, + ShuttingDown, + Stopped, + Failed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeStateSnapshot { + pub runtime_id: String, + pub runtime_version: String, + pub phase: RuntimePhase, + pub booted_at_unix_ms: u64, + pub uptime_ms: u64, + pub context_id: Option, + pub last_error: Option, + pub module_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleHealthSnapshot { + pub name: String, + pub phase: String, + pub healthy: bool, + pub detail: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthSnapshot { + pub runtime: RuntimeStateSnapshot, + pub modules: Vec, +} + +struct RuntimeStateInner { + phase: RuntimePhase, + context_id: Option, + last_error: Option, +} + +pub struct RuntimeStateStore { + runtime_id: String, + runtime_version: String, + booted_at_unix_ms: u64, + started_at: Instant, + inner: RwLock, +} + +impl RuntimeStateStore { + pub fn new(runtime_id: impl Into, runtime_version: impl Into) -> Self { + Self { + runtime_id: runtime_id.into(), + runtime_version: runtime_version.into(), + booted_at_unix_ms: unix_now_ms(), + started_at: Instant::now(), + inner: RwLock::new(RuntimeStateInner { + phase: RuntimePhase::Booting, + context_id: None, + last_error: None, + }), + } + } + + pub async fn transition(&self, phase: RuntimePhase) { + let mut inner = self.inner.write().await; + inner.phase = phase; + } + + pub async fn phase(&self) -> RuntimePhase { + self.inner.read().await.phase + } + + pub async fn attach_context(&self, context_id: String) { + let mut inner = self.inner.write().await; + inner.context_id = Some(context_id); + } + + pub async fn clear_context(&self) { + let mut inner = self.inner.write().await; + inner.context_id = None; + } + + pub async fn record_error(&self, error: impl Into) { + let mut inner = self.inner.write().await; + inner.last_error = Some(error.into()); + } + + pub async fn clear_error(&self) { + let mut inner = self.inner.write().await; + inner.last_error = None; + } + + pub async fn snapshot(&self, module_count: usize) -> RuntimeStateSnapshot { + let inner = self.inner.read().await; + RuntimeStateSnapshot { + runtime_id: self.runtime_id.clone(), + runtime_version: self.runtime_version.clone(), + phase: inner.phase, + booted_at_unix_ms: self.booted_at_unix_ms, + uptime_ms: self.started_at.elapsed().as_millis() as u64, + context_id: inner.context_id.clone(), + last_error: inner.last_error.clone(), + module_count, + } + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn runtime_version(&self) -> &str { + &self.runtime_version + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/diagnostics/mod.rs b/src-tauri/crates/runtime/src/infrastructure/diagnostics/mod.rs new file mode 100644 index 00000000..81f6922f --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/diagnostics/mod.rs @@ -0,0 +1,47 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy)] +pub enum LogLevel { + Debug, + Info, + Warn, + Error, +} + +impl LogLevel { + fn as_log_level(self) -> log::Level { + match self { + Self::Debug => log::Level::Debug, + Self::Info => log::Level::Info, + Self::Warn => log::Level::Warn, + Self::Error => log::Level::Error, + } + } +} + +pub fn unix_now_ms() -> u64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(_) => 0, + } +} + +pub fn log(level: LogLevel, target: &str, message: impl AsRef) { + log::log!(target: target, level.as_log_level(), "{}", message.as_ref()); +} + +pub fn log_debug(target: &str, message: impl AsRef) { + log(LogLevel::Debug, target, message); +} + +pub fn log_info(target: &str, message: impl AsRef) { + log(LogLevel::Info, target, message); +} + +pub fn log_warn(target: &str, message: impl AsRef) { + log(LogLevel::Warn, target, message); +} + +pub fn log_error(target: &str, message: impl AsRef) { + log(LogLevel::Error, target, message); +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/connection.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/connection.rs new file mode 100644 index 00000000..bfe809a1 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/connection.rs @@ -0,0 +1,145 @@ +use super::error::{EventBusError, Result}; +use super::message::{HandshakeData, Message, MessageType}; +use super::topics::Topic; +use super::transport::PipeConnection; +use crate::infrastructure::diagnostics::{log_debug, log_error, log_info, log_warn}; +use std::sync::Arc; +use tokio::sync::mpsc; + +pub type MessageHandler = Arc; + +pub struct BrowserConnection { + env_id: String, + connection: Arc, + is_handshake_complete: bool, +} + +impl BrowserConnection { + pub(crate) fn new(connection: PipeConnection) -> Self { + let env_id = connection.env_id().to_string(); + Self { + env_id, + connection: Arc::new(connection), + is_handshake_complete: false, + } + } + + pub fn env_id(&self) -> &str { + &self.env_id + } + + pub fn is_handshake_complete(&self) -> bool { + self.is_handshake_complete + } + + pub async fn handshake(&mut self) -> Result<()> { + log_info( + "eventbus", + format!("[{}] Waiting for handshake...", self.env_id), + ); + + let msg = self.connection.recv().await?; + if msg.topic != Topic::Handshake { + log_error( + "eventbus", + format!("[{}] Expected Handshake, got {:?}", self.env_id, msg.topic), + ); + return Err(EventBusError::InvalidMessage(format!( + "expected Handshake, got {:?}", + msg.topic + ))); + } + + let handshake_data = HandshakeData::from_bytes(&msg.data)?; + log_info( + "eventbus", + format!( + "[{}] Received handshake: version={}, client_type={}", + self.env_id, handshake_data.version, handshake_data.client_type + ), + ); + + if handshake_data.env_id != self.env_id { + log_warn( + "eventbus", + format!( + "[{}] env_id mismatch: expected {}, got {}", + self.env_id, self.env_id, handshake_data.env_id + ), + ); + } + + let response_data = HandshakeData::tauri_response().to_bytes()?; + let response = Message::success_response(msg.msg_id, Topic::Handshake, response_data); + self.connection.send(&response).await?; + + self.is_handshake_complete = true; + log_info("eventbus", format!("[{}] Handshake complete", self.env_id)); + Ok(()) + } + + pub async fn send(&self, msg: &Message) -> Result<()> { + if !self.is_handshake_complete { + return Err(EventBusError::NotConnected("handshake not complete".into())); + } + self.connection.send(msg).await + } + + pub async fn send_event(&self, topic: Topic, data: Vec) -> Result<()> { + let msg = Message::event(topic, data); + self.send(&msg).await + } + + pub async fn send_request(&self, topic: Topic, data: Vec) -> Result { + let msg = Message::request(topic, data); + let msg_id = msg.msg_id; + self.send(&msg).await?; + + loop { + let response = self.connection.recv().await?; + if response.msg_type == MessageType::Response && response.msg_id == msg_id { + return Ok(response); + } + log_debug( + "eventbus", + format!( + "[{}] Received unexpected message while waiting for response: {:?}", + self.env_id, response.topic + ), + ); + } + } + + pub async fn recv(&self) -> Result { + self.connection.recv().await + } + + pub fn start_recv_loop(self: Arc, handler: MessageHandler) -> mpsc::Sender<()> { + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1); + let env_id = self.env_id.clone(); + + tokio::spawn(async move { + log_info("eventbus", format!("[{}] Starting receive loop", env_id)); + + loop { + tokio::select! { + _ = shutdown_rx.recv() => { + log_info("eventbus", format!("[{}] Receive loop shutdown", env_id)); + break; + } + result = self.connection.recv() => { + match result { + Ok(msg) => handler(env_id.clone(), msg), + Err(error) => { + log_error("eventbus", format!("[{}] Receive error: {}", env_id, error)); + break; + } + } + } + } + } + }); + + shutdown_tx + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/error.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/error.rs new file mode 100644 index 00000000..7c9fa230 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/error.rs @@ -0,0 +1,67 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum EventBusError { + #[error("connection error: {0}")] + Connection(String), + + #[error("not connected: {0}")] + NotConnected(String), + + #[error("send failed: {0}")] + SendFailed(String), + + #[error("receive failed: {0}")] + ReceiveFailed(String), + + #[error("encode error: {0}")] + Encode(String), + + #[error("decode error: {0}")] + Decode(String), + + #[error("invalid message: {0}")] + InvalidMessage(String), + + #[error("unknown topic: {0}")] + UnknownTopic(u16), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("serialization error: {0}")] + Serialization(String), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ErrorCode { + Success = 0, + ConnectionFailed = 1001, + ConnectionLost = 1002, + SendFailed = 1003, + InvalidMessage = 2001, + UnknownTopic = 2002, + DecodeFailed = 2003, + InvalidConfig = 3001, + PermissionDenied = 3002, +} + +impl From for ErrorCode { + fn from(code: i32) -> Self { + match code { + 0 => Self::Success, + 1001 => Self::ConnectionFailed, + 1002 => Self::ConnectionLost, + 1003 => Self::SendFailed, + 2001 => Self::InvalidMessage, + 2002 => Self::UnknownTopic, + 2003 => Self::DecodeFailed, + 3001 => Self::InvalidConfig, + 3002 => Self::PermissionDenied, + _ => Self::InvalidMessage, + } + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/global.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/global.rs new file mode 100644 index 00000000..f9d90b4b --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/global.rs @@ -0,0 +1,75 @@ +use super::manager::EventBusManager; +use super::topics::Topic; +use crate::app::EventPublisher; +use crate::infrastructure::diagnostics::{log_debug, log_info}; +use serde::Serialize; +use tokio::sync::OnceCell; + +use std::sync::Arc; + +static EVENTBUS_MANAGER: OnceCell> = OnceCell::const_new(); + +#[derive(Clone, Serialize)] +pub struct EnvConnectionPayload { + pub env_id: String, + pub status: String, +} + +pub async fn init_eventbus_manager(events: EventPublisher) -> Arc { + EVENTBUS_MANAGER + .get_or_init(|| async move { + let manager = Arc::new(EventBusManager::new()); + + let forward_manager = manager.clone(); + manager + .set_message_handler(move |env_id, msg| { + if msg.topic == Topic::SyncInputEvent { + let data = msg.data; + let manager = forward_manager.clone(); + let sender = env_id.clone(); + tokio::spawn(async move { + manager.forward_sync_to_slaves(&sender, data).await; + }); + } else if msg.topic == Topic::SyncPaste { + let data = msg.data; + let manager = forward_manager.clone(); + let sender = env_id.clone(); + tokio::spawn(async move { + manager.forward_paste_to_slaves(&sender, data).await; + }); + } else if msg.topic == Topic::SyncInputDebug { + let s = String::from_utf8_lossy(&msg.data); + log_debug("eventbus", format!("[SyncInput] {}", s)); + } + }) + .await; + + let event_sink = events.clone(); + manager + .set_connection_status_handler(move |payload| { + let _ = event_sink.emit("eventbus.connection_status", &payload); + }) + .await; + + manager + .set_disconnect_handler(|env_id| { + log_info("eventbus", format!("Browser disconnected: {}", env_id)); + }) + .await; + + manager + }) + .await + .clone() +} + +pub fn get_eventbus_manager() -> Option> { + EVENTBUS_MANAGER.get().cloned() +} + +pub fn eventbus_manager() -> Arc { + EVENTBUS_MANAGER + .get() + .cloned() + .expect("EventBusManager not initialized. Call init_eventbus_manager() first.") +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/manager.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/manager.rs new file mode 100644 index 00000000..6ba0e41f --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/manager.rs @@ -0,0 +1,580 @@ +use super::connection::{BrowserConnection, MessageHandler}; +use super::error::{EventBusError, Result}; +use super::global::EnvConnectionPayload; +use super::message::{Message, MessageType}; +use super::topics::Topic; +use super::transport::PipeServer; +use crate::infrastructure::diagnostics::{log_error, log_info, log_warn}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{RwLock, mpsc, oneshot}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FingerprintConfig { + pub language: Option, + pub interface_language: Option, + pub timezone: Option, + pub geolocation_prompt: Option, + pub geolocation: Option, + pub platform: Option, + pub user_agent: Option, + pub sound: Option, + pub images: Option, + pub video: Option, + pub window_size: Option, + pub window_width: Option, + pub window_height: Option, + pub window_position: Option, + pub window_x: Option, + pub window_y: Option, + pub resolution: Option, + pub color_depth: Option, + pub device_pixel_ratio: Option, + pub max_touch_points: Option, + pub canvas: Option, + pub webgl_image: Option, + pub webgl_info: Option, + pub webgl_vendor: Option, + pub webgl_renderer: Option, + pub webgpu: Option, + pub font_fingerprint: Option, + pub font_list: Option, + pub audio_context: Option, + pub speech_voices: Option, + pub client_rects: Option, + pub media_devices: Option, + pub webrtc: Option, + pub do_not_track: Option, + pub device_name: Option, + pub device_name_random: Option, + pub mac_address: Option, + pub mac_address_mode: Option, + pub hardware_concurrency: Option, + pub device_memory: Option, + pub ssl_fingerprint: Option, + pub port_scan_protection: Option, + pub scan_whitelist: Option, + pub hardware_acceleration: Option, + pub disable_sandbox: Option, + pub startup_parameters: Option, + pub random_fingerprint_on_launch: Option, + pub env_id: Option, + pub env_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LaunchConfig { + pub env_uuid: String, + pub user_data_dir: String, + pub proxy: Option, + pub kernel_version: Option, + pub extensions: Option>, + pub custom_flags: Option>, + pub cookies: Option>, + pub urls: Option>, + pub fingerprint_config: Option, + pub accounts: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthInfo { + pub is_authenticated: bool, + pub access_token: Option, + pub user_info: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + pub user_id: String, + pub username: String, + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountConfig { + pub url: String, + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CookieGroup { + pub site: String, + pub cookie_text: String, +} + +type DisconnectHandler = Arc; +type ConnectionStatusHandler = Arc; +type AuthInfoProvider = Arc AuthInfo + Send + Sync>; + +pub struct EventBusManager { + connections: Arc>>>, + pending_requests: Arc>>>>, + message_handler: Arc>>, + disconnect_handler: Arc>>, + connection_status_handler: Arc>>, + auth_info_provider: Arc>>, + sync_master: Arc>>, + sync_slaves: Arc>>, +} + +impl EventBusManager { + pub fn new() -> Self { + Self { + connections: Arc::new(RwLock::new(HashMap::new())), + pending_requests: Arc::new(RwLock::new(HashMap::new())), + message_handler: Arc::new(RwLock::new(None)), + disconnect_handler: Arc::new(RwLock::new(None)), + connection_status_handler: Arc::new(RwLock::new(None)), + auth_info_provider: Arc::new(RwLock::new(None)), + sync_master: Arc::new(RwLock::new(None)), + sync_slaves: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn get_sync_state(&self) -> (Option, Vec) { + let master = self.sync_master.read().await.clone(); + let slaves = self.sync_slaves.read().await.clone(); + (master, slaves) + } + + pub async fn set_sync_state(&self, master: Option, slaves: Vec) { + let mut master_guard = self.sync_master.write().await; + let mut slaves_guard = self.sync_slaves.write().await; + *master_guard = master; + *slaves_guard = slaves; + } + + pub async fn send_to_envs( + &self, + env_ids: &[String], + topic: Topic, + data: Vec, + ) -> Vec<(String, Result<()>)> { + let connections = self.connections.read().await; + let message = Message::event(topic, data); + let mut results = Vec::new(); + for env_id in env_ids { + match connections.get(env_id) { + Some(connection) => { + let result = connection.send(&message).await; + if let Err(error) = &result { + log_warn( + "eventbus", + format!("Sync send to {} failed: {}", env_id, error), + ); + } + results.push((env_id.clone(), result)); + } + None => results.push(( + env_id.clone(), + Err(EventBusError::NotConnected(format!( + "env {} not connected", + env_id + ))), + )), + } + } + results + } + + pub async fn forward_sync_to_slaves(&self, sender_env_id: &str, data: Vec) { + let master = self.sync_master.read().await.clone(); + let slaves = self.sync_slaves.read().await.clone(); + let should_forward = matches!(master.as_ref(), Some(master_id) if master_id == sender_env_id) + && !slaves.is_empty(); + + if should_forward { + let results = self.send_to_envs(&slaves, Topic::SyncInputEvent, data).await; + let ok_count = results.iter().filter(|(_, result)| result.is_ok()).count(); + if ok_count < results.len() { + log_warn( + "eventbus", + format!( + "forward_sync: sent to {}/{} slaves", + ok_count, + results.len() + ), + ); + } + } + } + + pub async fn forward_paste_to_slaves(&self, sender_env_id: &str, data: Vec) { + let master = self.sync_master.read().await.clone(); + let slaves = self.sync_slaves.read().await.clone(); + let should_forward = matches!(master.as_ref(), Some(master_id) if master_id == sender_env_id) + && !slaves.is_empty(); + + if should_forward { + let _ = self.send_to_envs(&slaves, Topic::SyncPaste, data).await; + } + } + + async fn emit_connection_status(&self, env_id: &str, status: &str) { + let handler = self.connection_status_handler.read().await.clone(); + if let Some(handler) = handler { + handler(EnvConnectionPayload { + env_id: env_id.to_string(), + status: status.to_string(), + }); + } + } + + pub async fn set_message_handler(&self, handler: F) + where + F: Fn(String, Message) + Send + Sync + 'static, + { + let mut guard = self.message_handler.write().await; + *guard = Some(Arc::new(handler)); + } + + pub async fn set_disconnect_handler(&self, handler: F) + where + F: Fn(String) + Send + Sync + 'static, + { + let mut guard = self.disconnect_handler.write().await; + *guard = Some(Arc::new(handler)); + } + + pub async fn set_connection_status_handler(&self, handler: F) + where + F: Fn(EnvConnectionPayload) + Send + Sync + 'static, + { + let mut guard = self.connection_status_handler.write().await; + *guard = Some(Arc::new(handler)); + } + + pub async fn set_auth_info_provider(&self, provider: F) + where + F: Fn() -> AuthInfo + Send + Sync + 'static, + { + let mut guard = self.auth_info_provider.write().await; + *guard = Some(Arc::new(provider)); + } + + async fn create_server(&self, env_id: &str, launch_config: Option) -> Result<()> { + let server = PipeServer::new(env_id); + log_info("eventbus", format!("Creating server for env: {}", env_id)); + + let pipe_connection = server.accept().await?; + let mut browser_connection = BrowserConnection::new(pipe_connection); + browser_connection.handshake().await?; + + let env_id_owned = env_id.to_string(); + let browser_connection = Arc::new(browser_connection); + + { + let mut connections = self.connections.write().await; + connections.insert(env_id_owned.clone(), browser_connection.clone()); + } + + if let Some(config) = launch_config { + let config_data = serde_json::to_vec(&config) + .map_err(|error| EventBusError::Serialization(error.to_string()))?; + if let Err(error) = + browser_connection.send_event(Topic::LaunchConfig, config_data).await + { + self.connections.write().await.remove(&env_id_owned); + return Err(error); + } + log_info("eventbus", format!("[{}] Launch config sent", env_id_owned)); + + if let Some(fingerprint_config) = config.fingerprint_config { + let fingerprint_data = serde_json::to_vec(&fingerprint_config) + .map_err(|error| EventBusError::Serialization(error.to_string()))?; + if let Err(error) = + browser_connection.send_event(Topic::FingerprintApply, fingerprint_data).await + { + self.connections.write().await.remove(&env_id_owned); + return Err(error); + } + log_info( + "eventbus", + format!("[{}] Fingerprint config sent", env_id_owned), + ); + } + } + + self.emit_connection_status(&env_id_owned, "connected").await; + + let handler = self.message_handler.read().await.clone(); + let disconnect_handler = self.disconnect_handler.read().await.clone(); + let connections = self.connections.clone(); + let pending_requests = self.pending_requests.clone(); + let status_handler = self.connection_status_handler.clone(); + + if let Some(handler) = handler { + let env_id_for_loop = env_id_owned.clone(); + tokio::spawn(async move { + loop { + match browser_connection.recv().await { + Ok(msg) => { + if msg.msg_type == MessageType::Response { + let mut pending = pending_requests.write().await; + if let Some(waiters) = pending.get_mut(&env_id_for_loop) { + if let Some(sender) = waiters.remove(&msg.msg_id) { + let _ = sender.send(msg); + continue; + } + } + + log_warn( + "eventbus", + format!( + "[{}] Received unexpected response message: {}", + env_id_for_loop, msg.msg_id + ), + ); + continue; + } + + if msg.topic == Topic::AuthRequest { + if let Err(error) = + Self::handle_auth_request(&browser_connection, msg).await + { + log_warn( + "eventbus", + format!( + "[{}] Handle auth request failed: {}", + env_id_for_loop, error + ), + ); + } + continue; + } + + handler(env_id_for_loop.clone(), msg); + } + Err(error) => { + log_info( + "eventbus", + format!("[{}] Connection closed: {}", env_id_for_loop, error), + ); + + { + let mut guard = connections.write().await; + guard.remove(&env_id_for_loop); + } + + { + let mut pending = pending_requests.write().await; + pending.remove(&env_id_for_loop); + } + + { + let status = status_handler.read().await.clone(); + if let Some(status) = status { + status(EnvConnectionPayload { + env_id: env_id_for_loop.clone(), + status: "disconnected".into(), + }); + } + } + + if let Some(disconnect_handler) = &disconnect_handler { + disconnect_handler(env_id_for_loop.clone()); + } + + break; + } + } + } + }); + } + + Ok(()) + } + + pub fn start_server( + self: Arc, + env_id: String, + launch_config: Option, + ) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(1); + + tokio::spawn(async move { + tokio::select! { + result = self.create_server(&env_id, launch_config) => { + let _ = tx.send(result).await; + } + _ = tx.closed() => { + log_warn( + "eventbus", + format!("[{}] Startup listener cancelled", env_id), + ); + } + } + }); + + rx + } + + pub async fn send(&self, env_id: &str, msg: &Message) -> Result<()> { + let connections = self.connections.read().await; + let connection = connections + .get(env_id) + .ok_or_else(|| EventBusError::NotConnected(format!("env {} not connected", env_id)))?; + connection.send(msg).await + } + + pub async fn send_event(&self, env_id: &str, topic: Topic, data: Vec) -> Result<()> { + let message = Message::event(topic, data); + self.send(env_id, &message).await + } + + pub async fn send_request(&self, env_id: &str, topic: Topic, data: Vec) -> Result { + let connections = self.connections.read().await; + let connection = connections + .get(env_id) + .ok_or_else(|| EventBusError::NotConnected(format!("env {} not connected", env_id)))? + .clone(); + drop(connections); + + let message = Message::request(topic, data); + let msg_id = message.msg_id; + let (tx, rx) = oneshot::channel(); + + { + let mut pending = self.pending_requests.write().await; + pending.entry(env_id.to_string()).or_default().insert(msg_id, tx); + } + + if let Err(error) = connection.send(&message).await { + let mut pending = self.pending_requests.write().await; + if let Some(waiters) = pending.get_mut(env_id) { + waiters.remove(&msg_id); + } + return Err(error); + } + + rx.await.map_err(|error| { + EventBusError::ReceiveFailed(format!( + "failed to receive response for env {} msg {}: {}", + env_id, msg_id, error + )) + }) + } + + pub async fn broadcast(&self, topic: Topic, data: Vec) -> Vec<(String, Result<()>)> { + let connections = self.connections.read().await; + let message = Message::event(topic, data); + let mut results = Vec::new(); + for (env_id, connection) in connections.iter() { + let result = connection.send(&message).await; + if let Err(error) = &result { + log_warn( + "eventbus", + format!("Broadcast to {} failed: {}", env_id, error), + ); + } + results.push((env_id.clone(), result)); + } + results + } + + pub async fn connected_envs(&self) -> Vec { + let connections = self.connections.read().await; + connections.keys().cloned().collect() + } + + pub async fn connected_env_count(&self) -> usize { + let connections = self.connections.read().await; + connections.len() + } + + pub async fn is_connected(&self, env_id: &str) -> bool { + let connections = self.connections.read().await; + connections.contains_key(env_id) + } + + pub async fn disconnect(&self, env_id: &str) -> Result<()> { + let connections = self.connections.read().await; + if let Some(connection) = connections.get(env_id) { + let msg = Message::event(Topic::Disconnect, vec![]); + let result = connection.send(&msg).await; + if let Err(error) = &result { + log_warn( + "eventbus", + format!("Disconnect event to {} failed: {}", env_id, error), + ); + } + result + } else { + Err(EventBusError::NotConnected(format!( + "env {} not connected", + env_id + ))) + } + } + + pub async fn disconnect_all(&self) { + let mut connections = self.connections.write().await; + let mut pending_requests = self.pending_requests.write().await; + for (env_id, connection) in connections.drain() { + let message = Message::event(Topic::Disconnect, vec![]); + let _ = connection.send(&message).await; + pending_requests.remove(&env_id); + log_info("eventbus", format!("[{}] Disconnected", env_id)); + } + } + + async fn get_auth_info(&self) -> AuthInfo { + let provider = self.auth_info_provider.read().await.clone(); + match provider { + Some(provider) => provider(), + None => AuthInfo { + is_authenticated: false, + access_token: None, + user_info: None, + }, + } + } + + async fn handle_auth_request(conn: &Arc, msg: Message) -> Result<()> { + let manager = crate::infrastructure::eventbus::get_eventbus_manager().ok_or_else(|| { + EventBusError::NotConnected("eventbus manager not initialized".into()) + })?; + let auth_info = manager.get_auth_info().await; + + let auth_data = serde_json::to_vec(&auth_info) + .map_err(|error| EventBusError::Serialization(error.to_string()))?; + let response = Message::success_response(msg.msg_id, Topic::AuthResponse, auth_data); + conn.send(&response).await?; + Ok(()) + } + + pub async fn notify_auth_status_changed(&self) { + let connected_envs = self.connected_envs().await; + let auth_info = self.get_auth_info().await; + + let auth_data = match serde_json::to_vec(&auth_info) { + Ok(data) => data, + Err(error) => { + log_error( + "eventbus", + format!("Failed to serialize auth info: {}", error), + ); + return; + } + }; + + for env_id in connected_envs { + if let Err(error) = + self.send_event(&env_id, Topic::AuthResponse, auth_data.clone()).await + { + log_warn( + "eventbus", + format!("Failed to send auth status change to {}: {}", env_id, error), + ); + } + } + } +} + +impl Default for EventBusManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/message.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/message.rs new file mode 100644 index 00000000..5162fd85 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/message.rs @@ -0,0 +1,233 @@ +use super::error::{ErrorCode, EventBusError, Result}; +use super::topics::Topic; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicU32, Ordering}; + +const MAGIC: [u8; 4] = [0x53, 0x49, 0x4D, 0x00]; +pub const VERSION: u8 = 0x01; +const HEADER_SIZE: usize = 9; +const MIN_PAYLOAD_SIZE: usize = 15; + +static MSG_ID_COUNTER: AtomicU32 = AtomicU32::new(1); + +fn next_msg_id() -> u32 { + MSG_ID_COUNTER.fetch_add(1, Ordering::SeqCst) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum MessageType { + Request = 1, + Response = 2, + Event = 3, +} + +impl From for MessageType { + fn from(value: u8) -> Self { + match value { + 1 => Self::Request, + 2 => Self::Response, + 3 => Self::Event, + _ => Self::Event, + } + } +} + +#[derive(Debug, Clone)] +pub struct Message { + pub msg_id: u32, + pub msg_type: MessageType, + pub topic: Topic, + pub error_code: i32, + pub data: Vec, +} + +impl Message { + pub fn request(topic: Topic, data: Vec) -> Self { + Self { + msg_id: next_msg_id(), + msg_type: MessageType::Request, + topic, + error_code: 0, + data, + } + } + + pub fn event(topic: Topic, data: Vec) -> Self { + Self { + msg_id: next_msg_id(), + msg_type: MessageType::Event, + topic, + error_code: 0, + data, + } + } + + pub fn response(request_id: u32, topic: Topic, error_code: ErrorCode, data: Vec) -> Self { + Self { + msg_id: request_id, + msg_type: MessageType::Response, + topic, + error_code: error_code as i32, + data, + } + } + + pub fn success_response(request_id: u32, topic: Topic, data: Vec) -> Self { + Self::response(request_id, topic, ErrorCode::Success, data) + } + + pub fn error_response(request_id: u32, topic: Topic, error_code: ErrorCode) -> Self { + Self::response(request_id, topic, error_code, vec![]) + } + + pub fn encode(&self) -> Result { + let payload_len = MIN_PAYLOAD_SIZE + self.data.len(); + let mut payload = BytesMut::with_capacity(payload_len); + + payload.put_u32_le(self.msg_id); + payload.put_u8(self.msg_type as u8); + payload.put_u16_le(self.topic as u16); + payload.put_i32_le(self.error_code); + payload.put_u32_le(self.data.len() as u32); + payload.put_slice(&self.data); + + let mut buffer = BytesMut::with_capacity(HEADER_SIZE + payload.len()); + buffer.put_slice(&MAGIC); + buffer.put_u8(VERSION); + buffer.put_u32_le(payload.len() as u32); + buffer.put_slice(&payload); + + Ok(buffer.freeze()) + } + + pub fn decode(data: &[u8]) -> Result { + if data.len() < HEADER_SIZE { + return Err(EventBusError::Decode("data too short for header".into())); + } + + let mut buffer = data; + + let mut magic = [0u8; 4]; + magic.copy_from_slice(&buffer[..4]); + buffer.advance(4); + if magic != MAGIC { + return Err(EventBusError::Decode("invalid magic number".into())); + } + + let version = buffer.get_u8(); + if version != VERSION { + return Err(EventBusError::Decode(format!( + "unsupported version: {}", + version + ))); + } + + let payload_len = buffer.get_u32_le() as usize; + if buffer.len() < payload_len { + return Err(EventBusError::Decode("incomplete payload".into())); + } + if payload_len < MIN_PAYLOAD_SIZE { + return Err(EventBusError::Decode("payload too short".into())); + } + + let msg_id = buffer.get_u32_le(); + let msg_type = MessageType::from(buffer.get_u8()); + let topic = Topic::from(buffer.get_u16_le()); + let error_code = buffer.get_i32_le(); + let data_len = buffer.get_u32_le() as usize; + if buffer.len() < data_len { + return Err(EventBusError::Decode("data length mismatch".into())); + } + + let mut msg_data = vec![0u8; data_len]; + msg_data.copy_from_slice(&buffer[..data_len]); + + Ok(Self { + msg_id, + msg_type, + topic, + error_code, + data: msg_data, + }) + } + + pub fn try_decode(data: &[u8]) -> Result> { + if data.len() < HEADER_SIZE { + return Ok(None); + } + + if &data[..4] != MAGIC { + return Err(EventBusError::Decode("invalid magic number".into())); + } + + let payload_len = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize; + let total_len = HEADER_SIZE + payload_len; + if data.len() < total_len { + return Ok(None); + } + + let message = Self::decode(&data[..total_len])?; + Ok(Some((message, total_len))) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HandshakeData { + pub version: u8, + pub env_id: String, + pub client_type: String, +} + +impl HandshakeData { + pub fn browser(env_id: String) -> Self { + Self { + version: VERSION, + env_id, + client_type: "browser".into(), + } + } + + pub fn tauri_response() -> Self { + Self { + version: VERSION, + env_id: String::new(), + client_type: "tauri".into(), + } + } + + pub fn to_bytes(&self) -> Result> { + serde_json::to_vec(self).map_err(|error| EventBusError::Serialization(error.to_string())) + } + + pub fn from_bytes(data: &[u8]) -> Result { + serde_json::from_slice(data).map_err(|error| EventBusError::Decode(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_message_encode_decode() { + let message = Message::event(Topic::Handshake, b"hello".to_vec()); + let encoded = message.encode().unwrap(); + let decoded = Message::decode(&encoded).unwrap(); + + assert_eq!(decoded.msg_id, message.msg_id); + assert_eq!(decoded.topic, Topic::Handshake); + assert_eq!(decoded.data, b"hello".to_vec()); + } + + #[test] + fn test_handshake_data() { + let data = HandshakeData::browser("env_123".into()); + let bytes = data.to_bytes().unwrap(); + let decoded = HandshakeData::from_bytes(&bytes).unwrap(); + + assert_eq!(decoded.env_id, "env_123"); + assert_eq!(decoded.client_type, "browser"); + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/mod.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/mod.rs new file mode 100644 index 00000000..0b2f49e0 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/mod.rs @@ -0,0 +1,20 @@ +mod connection; +mod error; +mod global; +mod manager; +mod message; +mod topics; +mod transport; + +pub use connection::BrowserConnection; +pub use error::{ErrorCode, EventBusError, Result}; +pub use global::{ + EnvConnectionPayload, eventbus_manager, get_eventbus_manager, init_eventbus_manager, +}; +pub use manager::{ + AccountConfig, AuthInfo, CookieGroup, EventBusManager, FingerprintConfig, LaunchConfig, + UserInfo, +}; +pub use message::{HandshakeData, Message, MessageType}; +pub use topics::Topic; +pub use transport::get_pipe_path; diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/topics.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/topics.rs new file mode 100644 index 00000000..67aa2b1b --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/topics.rs @@ -0,0 +1,60 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u16)] +pub enum Topic { + Handshake = 0x0001, + Heartbeat = 0x0002, + Disconnect = 0x0003, + ConfigInit = 0x0100, + ConfigUpdate = 0x0101, + FingerprintApply = 0x0200, + FingerprintQuery = 0x0201, + ProxySet = 0x0300, + ProxyBypass = 0x0301, + RpaCommand = 0x0400, + RpaResult = 0x0401, + RpaEvent = 0x0402, + PageLoad = 0x0500, + PageClose = 0x0501, + NavigationStart = 0x0502, + AuthRequest = 0x0600, + AuthResponse = 0x0601, + WindowSetBounds = 0x0700, + SyncInputEvent = 0x0800, + SyncRole = 0x0801, + SyncInputDebug = 0x0802, + SyncPaste = 0x0803, + LaunchConfig = 0x0900, +} + +impl From for Topic { + fn from(value: u16) -> Self { + match value { + 0x0001 => Self::Handshake, + 0x0002 => Self::Heartbeat, + 0x0003 => Self::Disconnect, + 0x0100 => Self::ConfigInit, + 0x0101 => Self::ConfigUpdate, + 0x0200 => Self::FingerprintApply, + 0x0201 => Self::FingerprintQuery, + 0x0300 => Self::ProxySet, + 0x0301 => Self::ProxyBypass, + 0x0400 => Self::RpaCommand, + 0x0401 => Self::RpaResult, + 0x0402 => Self::RpaEvent, + 0x0500 => Self::PageLoad, + 0x0501 => Self::PageClose, + 0x0502 => Self::NavigationStart, + 0x0600 => Self::AuthRequest, + 0x0601 => Self::AuthResponse, + 0x0700 => Self::WindowSetBounds, + 0x0800 => Self::SyncInputEvent, + 0x0801 => Self::SyncRole, + 0x0802 => Self::SyncInputDebug, + 0x0803 => Self::SyncPaste, + 0x0900 => Self::LaunchConfig, + _ => Self::Handshake, + } + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/eventbus/transport.rs b/src-tauri/crates/runtime/src/infrastructure/eventbus/transport.rs new file mode 100644 index 00000000..6d61156e --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/eventbus/transport.rs @@ -0,0 +1,263 @@ +use super::error::{EventBusError, Result}; +use super::message::Message; + +#[cfg(windows)] +mod imp { + use super::*; + use crate::infrastructure::diagnostics::{log_error, log_info}; + use bytes::BytesMut; + use std::ffi::OsString; + use std::sync::Arc; + use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; + use tokio::sync::Mutex; + + const PIPE_PREFIX: &str = r"\\.\pipe\simprint_"; + const READ_BUFFER_SIZE: usize = 65536; + + pub fn get_pipe_path(env_id: &str) -> String { + format!("{}{}", PIPE_PREFIX, env_id) + } + + #[allow(dead_code)] + pub struct PipeServer { + env_id: String, + pipe_path: String, + } + + #[allow(dead_code)] + impl PipeServer { + pub fn new(env_id: &str) -> Self { + Self { + env_id: env_id.to_string(), + pipe_path: get_pipe_path(env_id), + } + } + + pub fn env_id(&self) -> &str { + &self.env_id + } + + pub fn pipe_path(&self) -> &str { + &self.pipe_path + } + + pub async fn accept(&self) -> Result { + let pipe_path_os: OsString = OsString::from(&self.pipe_path); + let server = ServerOptions::new() + .first_pipe_instance(true) + .create(&pipe_path_os) + .map_err(|error| { + log_error("eventbus", format!("Failed to create pipe: {}", error)); + EventBusError::Connection(format!("failed to create pipe: {}", error)) + })?; + + log_info( + "eventbus", + format!("Waiting for connection on: {}", self.pipe_path), + ); + + server.connect().await.map_err(|error| { + log_error( + "eventbus", + format!("Failed to accept connection: {}", error), + ); + EventBusError::Connection(format!("failed to accept connection: {}", error)) + })?; + + log_info( + "eventbus", + format!("Client connected on: {}", self.pipe_path), + ); + + Ok(PipeConnection::new(server, self.env_id.clone())) + } + } + + pub struct PipeConnection { + pipe: Arc, + env_id: String, + read_buffer: Arc>, + } + + impl PipeConnection { + fn new(pipe: NamedPipeServer, env_id: String) -> Self { + Self { + pipe: Arc::new(pipe), + env_id, + read_buffer: Arc::new(Mutex::new(BytesMut::with_capacity(READ_BUFFER_SIZE))), + } + } + + pub fn env_id(&self) -> &str { + &self.env_id + } + + pub async fn send(&self, msg: &Message) -> Result<()> { + let data = msg.encode()?; + + let mut pos = 0; + while pos < data.len() { + self.pipe.writable().await.map_err(|error| { + log_error("eventbus", format!("Writable wait failed: {}", error)); + EventBusError::SendFailed(error.to_string()) + })?; + + match self.pipe.try_write(&data[pos..]) { + Ok(n) => pos += n, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(error) => { + log_error("eventbus", format!("Send failed: {}", error)); + return Err(EventBusError::SendFailed(error.to_string())); + } + } + } + + Ok(()) + } + + pub async fn recv(&self) -> Result { + let mut temp_buf = [0u8; READ_BUFFER_SIZE]; + + loop { + { + let mut buffer = self.read_buffer.lock().await; + if let Some((msg, consumed)) = Message::try_decode(&buffer)? { + let _ = buffer.split_to(consumed); + return Ok(msg); + } + } + + self.pipe.readable().await.map_err(|error| { + log_error("eventbus", format!("Readable wait failed: {}", error)); + EventBusError::ReceiveFailed(error.to_string()) + })?; + + match self.pipe.try_read(&mut temp_buf) { + Ok(0) => return Err(EventBusError::ReceiveFailed("connection closed".into())), + Ok(n) => { + let mut buffer = self.read_buffer.lock().await; + buffer.extend_from_slice(&temp_buf[..n]); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(error) => { + log_error("eventbus", format!("Read failed: {}", error)); + return Err(EventBusError::ReceiveFailed(error.to_string())); + } + } + } + } + + pub async fn try_recv(&self) -> Result> { + { + let mut buffer = self.read_buffer.lock().await; + if let Some((msg, consumed)) = Message::try_decode(&buffer)? { + let _ = buffer.split_to(consumed); + return Ok(Some(msg)); + } + } + + let mut temp_buf = [0u8; READ_BUFFER_SIZE]; + let n = match self.pipe.try_read(&mut temp_buf) { + Ok(n) => n, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(error) => return Err(EventBusError::ReceiveFailed(error.to_string())), + }; + + if n == 0 { + return Err(EventBusError::ReceiveFailed("connection closed".into())); + } + + { + let mut buffer = self.read_buffer.lock().await; + buffer.extend_from_slice(&temp_buf[..n]); + if let Some((msg, consumed)) = Message::try_decode(&buffer)? { + let _ = buffer.split_to(consumed); + return Ok(Some(msg)); + } + } + + Ok(None) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_pipe_path() { + let path = get_pipe_path("env_123"); + assert_eq!(path, r"\\.\pipe\simprint_env_123"); + } + } +} + +#[cfg(not(windows))] +#[allow(dead_code)] +mod imp { + use super::*; + + pub fn get_pipe_path(env_id: &str) -> String { + format!("simprint_{}", env_id) + } + + pub struct PipeServer { + env_id: String, + pipe_path: String, + } + + impl PipeServer { + pub fn new(env_id: &str) -> Self { + Self { + env_id: env_id.to_string(), + pipe_path: get_pipe_path(env_id), + } + } + + pub fn env_id(&self) -> &str { + &self.env_id + } + + pub fn pipe_path(&self) -> &str { + &self.pipe_path + } + + pub async fn accept(&self) -> Result { + Err(EventBusError::Connection( + "eventbus named pipe transport is only implemented on Windows".into(), + )) + } + } + + #[allow(dead_code)] + pub struct PipeConnection { + env_id: String, + } + + #[allow(dead_code)] + impl PipeConnection { + pub fn env_id(&self) -> &str { + &self.env_id + } + + pub async fn send(&self, _msg: &Message) -> Result<()> { + Err(EventBusError::SendFailed( + "eventbus named pipe transport is unavailable on this platform".into(), + )) + } + + pub async fn recv(&self) -> Result { + Err(EventBusError::ReceiveFailed( + "eventbus named pipe transport is unavailable on this platform".into(), + )) + } + + pub async fn try_recv(&self) -> Result> { + Err(EventBusError::ReceiveFailed( + "eventbus named pipe transport is unavailable on this platform".into(), + )) + } + } +} + +pub use imp::{PipeConnection, PipeServer, get_pipe_path}; diff --git a/src-tauri/crates/runtime/src/infrastructure/ipc/error.rs b/src-tauri/crates/runtime/src/infrastructure/ipc/error.rs new file mode 100644 index 00000000..e49a8f88 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/ipc/error.rs @@ -0,0 +1,58 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum IpcError { + #[error("connection error: {0}")] + Connection(String), + + #[error("connection closed")] + ConnectionClosed, + + #[error("send failed: {0}")] + SendFailed(String), + + #[error("receive failed: {0}")] + ReceiveFailed(String), + + #[error("encode error: {0}")] + Encode(String), + + #[error("decode error: {0}")] + Decode(String), + + #[error("invalid message: {0}")] + InvalidMessage(String), + + #[error("serialization error: {0}")] + Serialization(String), + + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(i32)] +pub enum ErrorCode { + Success = 0, + ConnectionFailed = 1001, + ConnectionClosed = 1002, + SendFailed = 1003, + InvalidMessage = 2001, + UnknownTopic = 2002, + DecodeFailed = 2003, + HandshakeRequired = 2004, + InvalidState = 3001, + AlreadyInitialized = 3002, + NotInitialized = 3003, + ModuleFailed = 4001, + NotImplemented = 4002, + InternalError = 5000, +} + +impl ErrorCode { + pub fn as_i32(self) -> i32 { + self as i32 + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/ipc/message.rs b/src-tauri/crates/runtime/src/infrastructure/ipc/message.rs new file mode 100644 index 00000000..6631be75 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/ipc/message.rs @@ -0,0 +1,276 @@ +use super::error::{ErrorCode, IpcError, Result}; +use super::topics::Topic; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use serde::{Serialize, de::DeserializeOwned}; +use std::sync::atomic::{AtomicU32, Ordering}; + +const MAGIC: [u8; 4] = [0x53, 0x49, 0x4D, 0x00]; +pub const PROTOCOL_VERSION: u8 = 0x01; +const HEADER_SIZE: usize = 9; +const MIN_PAYLOAD_SIZE: usize = 15; + +static MESSAGE_ID_COUNTER: AtomicU32 = AtomicU32::new(1); + +fn next_message_id() -> u32 { + MESSAGE_ID_COUNTER.fetch_add(1, Ordering::SeqCst) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum MessageType { + Request = 1, + Response = 2, + Event = 3, +} + +impl TryFrom for MessageType { + type Error = IpcError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::Request), + 2 => Ok(Self::Response), + 3 => Ok(Self::Event), + other => Err(IpcError::InvalidMessage(format!( + "unknown message type: {}", + other + ))), + } + } +} + +#[derive(Debug, Clone)] +pub struct Message { + pub msg_id: u32, + pub msg_type: MessageType, + pub topic: Topic, + pub error_code: i32, + pub data: Vec, +} + +impl Message { + pub fn request(topic: Topic, data: Vec) -> Self { + Self { + msg_id: next_message_id(), + msg_type: MessageType::Request, + topic, + error_code: 0, + data, + } + } + + pub fn request_payload(topic: Topic, payload: &T) -> Result { + Ok(Self::request(topic, encode_payload(payload)?)) + } + + pub fn event(topic: Topic, data: Vec) -> Self { + Self { + msg_id: next_message_id(), + msg_type: MessageType::Event, + topic, + error_code: 0, + data, + } + } + + pub fn event_payload(topic: Topic, payload: &T) -> Result { + Ok(Self::event(topic, encode_payload(payload)?)) + } + + pub fn response(request_id: u32, topic: Topic, error_code: ErrorCode, data: Vec) -> Self { + Self { + msg_id: request_id, + msg_type: MessageType::Response, + topic, + error_code: error_code.as_i32(), + data, + } + } + + pub fn success_response(request_id: u32, topic: Topic, data: Vec) -> Self { + Self::response(request_id, topic, ErrorCode::Success, data) + } + + pub fn success_response_payload( + request_id: u32, + topic: Topic, + payload: &T, + ) -> Result { + Ok(Self::success_response( + request_id, + topic, + encode_payload(payload)?, + )) + } + + pub fn error_response( + request_id: u32, + topic: Topic, + error_code: ErrorCode, + data: Vec, + ) -> Self { + Self::response(request_id, topic, error_code, data) + } + + pub fn error_response_payload( + request_id: u32, + topic: Topic, + error_code: ErrorCode, + payload: &T, + ) -> Result { + Ok(Self::error_response( + request_id, + topic, + error_code, + encode_payload(payload)?, + )) + } + + pub fn payload(&self) -> Result { + decode_payload(&self.data) + } + + pub fn encode(&self) -> Result { + let payload_len = MIN_PAYLOAD_SIZE + self.data.len(); + let mut payload = BytesMut::with_capacity(payload_len); + + payload.put_u32_le(self.msg_id); + payload.put_u8(self.msg_type as u8); + payload.put_u16_le(u16::from(self.topic)); + payload.put_i32_le(self.error_code); + payload.put_u32_le(self.data.len() as u32); + payload.put_slice(&self.data); + + let mut frame = BytesMut::with_capacity(HEADER_SIZE + payload.len()); + frame.put_slice(&MAGIC); + frame.put_u8(PROTOCOL_VERSION); + frame.put_u32_le(payload.len() as u32); + frame.put_slice(&payload); + + Ok(frame.freeze()) + } + + pub fn decode(data: &[u8]) -> Result { + if data.len() < HEADER_SIZE { + return Err(IpcError::Decode("frame shorter than header".into())); + } + + let mut buffer = data; + + let mut magic = [0u8; 4]; + magic.copy_from_slice(&buffer[..4]); + buffer.advance(4); + if magic != MAGIC { + return Err(IpcError::Decode("invalid magic number".into())); + } + + let version = buffer.get_u8(); + if version != PROTOCOL_VERSION { + return Err(IpcError::Decode(format!( + "unsupported protocol version: {}", + version + ))); + } + + let payload_len = buffer.get_u32_le() as usize; + if buffer.len() < payload_len { + return Err(IpcError::Decode("incomplete payload".into())); + } + if payload_len < MIN_PAYLOAD_SIZE { + return Err(IpcError::Decode("payload too short".into())); + } + + let msg_id = buffer.get_u32_le(); + let msg_type = MessageType::try_from(buffer.get_u8())?; + let topic = Topic::from(buffer.get_u16_le()); + let error_code = buffer.get_i32_le(); + let data_len = buffer.get_u32_le() as usize; + if buffer.len() < data_len { + return Err(IpcError::Decode("data length mismatch".into())); + } + + let mut msg_data = vec![0u8; data_len]; + msg_data.copy_from_slice(&buffer[..data_len]); + + Ok(Self { + msg_id, + msg_type, + topic, + error_code, + data: msg_data, + }) + } + + pub fn try_decode(data: &[u8]) -> Result> { + if data.len() < HEADER_SIZE { + return Ok(None); + } + + if &data[..4] != MAGIC.as_slice() { + return Err(IpcError::Decode("invalid magic number".into())); + } + + let payload_len = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize; + let total_len = HEADER_SIZE + payload_len; + if data.len() < total_len { + return Ok(None); + } + + Ok(Some((Self::decode(&data[..total_len])?, total_len))) + } +} + +pub fn encode_payload(payload: &T) -> Result> { + rmp_serde::to_vec_named(payload).map_err(|error| IpcError::Serialization(error.to_string())) +} + +pub fn decode_payload(data: &[u8]) -> Result { + rmp_serde::from_slice(data).map_err(|error| IpcError::Decode(error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + struct Payload { + name: String, + value: u32, + } + + #[test] + fn roundtrip_message_encoding() { + let payload = Payload { + name: "runtime".into(), + value: 7, + }; + let message = Message::request_payload(Topic::Ping, &payload).unwrap(); + let encoded = message.encode().unwrap(); + let decoded = Message::decode(&encoded).unwrap(); + + assert_eq!(decoded.msg_id, message.msg_id); + assert_eq!(decoded.msg_type, MessageType::Request); + assert_eq!(decoded.topic, Topic::Ping); + assert_eq!(decoded.payload::().unwrap(), payload); + } + + #[test] + fn try_decode_waits_for_full_frame() { + let message = Message::event_payload( + Topic::RuntimeEvent, + &Payload { + name: "state".into(), + value: 1, + }, + ) + .unwrap(); + let encoded = message.encode().unwrap(); + + assert!(Message::try_decode(&encoded[..4]).unwrap().is_none()); + + let (decoded, consumed) = Message::try_decode(&encoded).unwrap().unwrap(); + assert_eq!(consumed, encoded.len()); + assert_eq!(decoded.topic, Topic::RuntimeEvent); + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/ipc/mod.rs b/src-tauri/crates/runtime/src/infrastructure/ipc/mod.rs new file mode 100644 index 00000000..5979a218 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/ipc/mod.rs @@ -0,0 +1,7 @@ +mod error; +mod message; +mod topics; + +pub use error::{ErrorCode, IpcError, Result}; +pub use message::{Message, MessageType, PROTOCOL_VERSION}; +pub use topics::Topic; diff --git a/src-tauri/crates/runtime/src/infrastructure/ipc/topics.rs b/src-tauri/crates/runtime/src/infrastructure/ipc/topics.rs new file mode 100644 index 00000000..a2d7ec6e --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/ipc/topics.rs @@ -0,0 +1,53 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Topic { + Handshake, + Ping, + QueryState, + QueryHealth, + Shutdown, + InitializeContext, + DestroyContext, + EnvironmentCommand, + SyncCommand, + AuthCommand, + RuntimeEvent, + Unknown(u16), +} + +impl From for Topic { + fn from(value: u16) -> Self { + match value { + 0x0001 => Self::Handshake, + 0x0002 => Self::Ping, + 0x0003 => Self::QueryState, + 0x0004 => Self::QueryHealth, + 0x0005 => Self::Shutdown, + 0x0100 => Self::InitializeContext, + 0x0101 => Self::DestroyContext, + 0x0200 => Self::EnvironmentCommand, + 0x0300 => Self::SyncCommand, + 0x0400 => Self::AuthCommand, + 0x7F00 => Self::RuntimeEvent, + unknown => Self::Unknown(unknown), + } + } +} + +impl From for u16 { + fn from(topic: Topic) -> Self { + match topic { + Topic::Handshake => 0x0001, + Topic::Ping => 0x0002, + Topic::QueryState => 0x0003, + Topic::QueryHealth => 0x0004, + Topic::Shutdown => 0x0005, + Topic::InitializeContext => 0x0100, + Topic::DestroyContext => 0x0101, + Topic::EnvironmentCommand => 0x0200, + Topic::SyncCommand => 0x0300, + Topic::AuthCommand => 0x0400, + Topic::RuntimeEvent => 0x7F00, + Topic::Unknown(value) => value, + } + } +} diff --git a/src-tauri/crates/runtime/src/infrastructure/mod.rs b/src-tauri/crates/runtime/src/infrastructure/mod.rs new file mode 100644 index 00000000..27aaa973 --- /dev/null +++ b/src-tauri/crates/runtime/src/infrastructure/mod.rs @@ -0,0 +1,3 @@ +pub mod diagnostics; +pub mod eventbus; +pub mod ipc; diff --git a/src-tauri/crates/runtime/src/lib.rs b/src-tauri/crates/runtime/src/lib.rs new file mode 100644 index 00000000..0779b78e --- /dev/null +++ b/src-tauri/crates/runtime/src/lib.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod infrastructure; +pub mod services; diff --git a/src-tauri/crates/runtime/src/services/auth/mod.rs b/src-tauri/crates/runtime/src/services/auth/mod.rs new file mode 100644 index 00000000..d6460779 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/auth/mod.rs @@ -0,0 +1,252 @@ +pub mod types; + +use crate::app::{ + ModuleContext, ModuleHealthSnapshot, Result, RuntimeContext, RuntimeError, RuntimeModule, +}; +use crate::infrastructure::eventbus::{AuthInfo, get_eventbus_manager}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::{Arc, RwLock}; +use tokio::sync::RwLock as AsyncRwLock; +use types::{AuthCommandRequest, AuthCommandResponse}; + +#[derive(Debug, Clone, Copy)] +enum AuthPhase { + Dormant, + RuntimeStarted, + ContextReady, + RuntimeStopped, +} + +impl AuthPhase { + fn as_str(self) -> &'static str { + match self { + Self::Dormant => "dormant", + Self::RuntimeStarted => "runtime_started", + Self::ContextReady => "context_ready", + Self::RuntimeStopped => "runtime_stopped", + } + } +} + +struct AuthState { + phase: AuthPhase, + last_error: Option, +} + +pub struct AuthStateStore { + auth_info: RwLock, +} + +impl AuthStateStore { + pub fn new() -> Self { + Self { + auth_info: RwLock::new(anonymous_auth_info()), + } + } + + pub fn snapshot(&self) -> AuthInfo { + self.auth_info.read().expect("auth state poisoned").clone() + } + + pub fn replace(&self, auth_info: AuthInfo) { + *self.auth_info.write().expect("auth state poisoned") = auth_info; + } + + pub fn clear(&self) { + self.replace(anonymous_auth_info()); + } +} + +pub struct AuthRuntime { + state: AsyncRwLock, + auth_state: Arc, +} + +impl AuthRuntime { + pub fn new(auth_state: Arc) -> Self { + Self { + state: AsyncRwLock::new(AuthState { + phase: AuthPhase::Dormant, + last_error: None, + }), + auth_state, + } + } + + pub async fn execute_command( + &self, + command: AuthCommandRequest, + ) -> Result { + let phase = self.state.read().await.phase; + if !matches!(phase, AuthPhase::ContextReady) { + return Err(RuntimeError::InvalidState( + "auth runtime requires initialized context".into(), + )); + } + + match command { + AuthCommandRequest::SetAuthState { auth_info } => { + self.replace_auth_info(auth_info).await; + Ok(AuthCommandResponse::Ack) + } + AuthCommandRequest::ClearAuthState => { + self.clear_auth_info().await; + Ok(AuthCommandResponse::Ack) + } + AuthCommandRequest::GetAuthState => Ok(AuthCommandResponse::State { + auth_info: self.auth_state.snapshot(), + }), + } + } + + pub fn state_store(&self) -> Arc { + self.auth_state.clone() + } + + async fn replace_auth_info(&self, auth_info: AuthInfo) { + self.auth_state.replace(auth_info); + self.notify_auth_status_changed().await; + } + + async fn clear_auth_info(&self) { + self.auth_state.clear(); + self.notify_auth_status_changed().await; + } + + async fn notify_auth_status_changed(&self) { + if let Some(manager) = get_eventbus_manager() { + manager.notify_auth_status_changed().await; + } + } +} + +#[async_trait] +impl RuntimeModule for AuthRuntime { + fn name(&self) -> &'static str { + "auth" + } + + async fn on_runtime_start(&self, _context: ModuleContext) -> Result<()> { + self.auth_state.clear(); + let mut state = self.state.write().await; + state.phase = AuthPhase::RuntimeStarted; + state.last_error = None; + Ok(()) + } + + async fn on_context_initialize(&self, context: RuntimeContext) -> Result<()> { + let mut state = self.state.write().await; + if !matches!(state.phase, AuthPhase::RuntimeStarted) { + return Err(RuntimeError::InvalidState( + "auth runtime requires runtime_started before context init".into(), + )); + } + state.phase = AuthPhase::ContextReady; + drop(state); + + match context.input.auth_info { + Some(auth_info) => self.replace_auth_info(auth_info).await, + None => self.clear_auth_info().await, + } + + Ok(()) + } + + async fn on_context_destroy(&self) -> Result<()> { + self.clear_auth_info().await; + let mut state = self.state.write().await; + state.phase = AuthPhase::RuntimeStarted; + Ok(()) + } + + async fn on_runtime_shutdown(&self) -> Result<()> { + self.clear_auth_info().await; + let mut state = self.state.write().await; + state.phase = AuthPhase::RuntimeStopped; + Ok(()) + } + + async fn health_snapshot(&self) -> ModuleHealthSnapshot { + let state = self.state.read().await; + let auth_info = self.auth_state.snapshot(); + ModuleHealthSnapshot { + name: self.name().into(), + phase: state.phase.as_str().into(), + healthy: state.last_error.is_none(), + detail: json!({ + "is_authenticated": auth_info.is_authenticated, + "has_access_token": auth_info.access_token.is_some(), + "has_user_info": auth_info.user_info.is_some(), + "last_error": state.last_error, + }), + } + } +} + +fn anonymous_auth_info() -> AuthInfo { + AuthInfo { + is_authenticated: false, + access_token: None, + user_info: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ModuleContext, RuntimeContextInput, event_channel}; + use crate::infrastructure::eventbus::UserInfo; + + #[tokio::test] + async fn auth_runtime_initializes_from_context_and_updates_state() { + let auth_state = Arc::new(AuthStateStore::new()); + let runtime = AuthRuntime::new(auth_state.clone()); + let (events, _) = event_channel(); + + runtime.on_runtime_start(ModuleContext { events }).await.unwrap(); + runtime + .on_context_initialize(RuntimeContext::new( + 1, + RuntimeContextInput { + user_id: Some("user-1".into()), + workspace_id: None, + auth_info: Some(AuthInfo { + is_authenticated: true, + access_token: Some("token-1".into()), + user_info: Some(UserInfo { + user_id: "user-1".into(), + username: "tester".into(), + email: Some("tester@example.com".into()), + }), + }), + attributes: Default::default(), + }, + )) + .await + .unwrap(); + + assert_eq!(auth_state.snapshot().is_authenticated, true); + + runtime + .execute_command(AuthCommandRequest::SetAuthState { + auth_info: AuthInfo { + is_authenticated: true, + access_token: Some("token-2".into()), + user_info: None, + }, + }) + .await + .unwrap(); + + match runtime.execute_command(AuthCommandRequest::GetAuthState).await.unwrap() { + AuthCommandResponse::State { auth_info } => { + assert_eq!(auth_info.access_token.as_deref(), Some("token-2")); + } + other => panic!("unexpected response: {:?}", other), + } + + runtime.on_context_destroy().await.unwrap(); + assert_eq!(auth_state.snapshot().is_authenticated, false); + } +} diff --git a/src-tauri/crates/runtime/src/services/auth/types.rs b/src-tauri/crates/runtime/src/services/auth/types.rs new file mode 100644 index 00000000..b99386d0 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/auth/types.rs @@ -0,0 +1,17 @@ +use crate::infrastructure::eventbus::AuthInfo; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum AuthCommandRequest { + SetAuthState { auth_info: AuthInfo }, + ClearAuthState, + GetAuthState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AuthCommandResponse { + Ack, + State { auth_info: AuthInfo }, +} diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/cdp.rs b/src-tauri/crates/runtime/src/services/environment/kernel/cdp.rs new file mode 100644 index 00000000..0c1f5f0b --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/kernel/cdp.rs @@ -0,0 +1,116 @@ +use crate::app::{Result, RuntimeError}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::net::TcpListener; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; + +const CDP_HOST: &str = "127.0.0.1"; +const CDP_PORT_START: u16 = 29200; +const CDP_PORT_END: u16 = 29499; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdpEndpointInfo { + pub env_uuid: String, + pub host: String, + pub port: u16, + pub version_url: String, + pub list_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_ws_url: Option, +} + +pub struct CdpEndpointManager { + ports: Arc>>, +} + +impl CdpEndpointManager { + pub fn new() -> Self { + Self { + ports: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn allocate_port(&self, env_uuid: &str) -> Result { + { + let ports = self.ports.read().await; + if let Some(port) = ports.get(env_uuid) { + return Ok(*port); + } + } + + let mut ports = self.ports.write().await; + if let Some(port) = ports.get(env_uuid) { + return Ok(*port); + } + + let used_ports: HashSet = ports.values().copied().collect(); + let port = find_available_port(&used_ports).ok_or_else(|| { + RuntimeError::Internal("No available CDP port in configured range".into()) + })?; + ports.insert(env_uuid.to_string(), port); + Ok(port) + } + + pub async fn remove(&self, env_uuid: &str) { + let mut ports = self.ports.write().await; + ports.remove(env_uuid); + } + + pub async fn clear_all(&self) { + let mut ports = self.ports.write().await; + ports.clear(); + } + + pub async fn get_port(&self, env_uuid: &str) -> Option { + let ports = self.ports.read().await; + ports.get(env_uuid).copied() + } + + pub async fn get_endpoint(&self, env_uuid: &str) -> Option { + let port = self.get_port(env_uuid).await?; + let version_url = format!("http://{}:{}/json/version", CDP_HOST, port); + let list_url = format!("http://{}:{}/json/list", CDP_HOST, port); + let browser_ws_url = query_browser_ws_url(&version_url).await; + + Some(CdpEndpointInfo { + env_uuid: env_uuid.to_string(), + host: CDP_HOST.to_string(), + port, + version_url, + list_url, + browser_ws_url, + }) + } +} + +impl Default for CdpEndpointManager { + fn default() -> Self { + Self::new() + } +} + +fn find_available_port(used_ports: &HashSet) -> Option { + for port in CDP_PORT_START..=CDP_PORT_END { + if used_ports.contains(&port) { + continue; + } + + if TcpListener::bind((CDP_HOST, port)).is_ok() { + return Some(port); + } + } + + None +} + +async fn query_browser_ws_url(version_url: &str) -> Option { + let client = reqwest::Client::builder().timeout(Duration::from_secs(1)).build().ok()?; + let response = client.get(version_url).send().await.ok()?; + let payload = response.json::().await.ok()?; + payload + .get("webSocketDebuggerUrl") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned) +} diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/job.rs b/src-tauri/crates/runtime/src/services/environment/kernel/job.rs new file mode 100644 index 00000000..ef51bb5c --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/kernel/job.rs @@ -0,0 +1,132 @@ +use crate::app::{Result, RuntimeError}; +#[cfg(target_os = "windows")] +use std::collections::HashMap; +#[cfg(target_os = "windows")] +use std::sync::Arc; +#[cfg(target_os = "windows")] +use tokio::sync::RwLock; + +#[cfg(target_os = "windows")] +use windows::Win32::{ + Foundation::{CloseHandle, HANDLE}, + System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}, +}; + +#[cfg(target_os = "windows")] +pub struct JobHandle { + handle: HANDLE, +} + +#[cfg(target_os = "windows")] +impl JobHandle { + pub fn create() -> Result { + unsafe { + let handle = CreateJobObjectW(None, None).map_err(|error| { + RuntimeError::Internal(format!("Failed to create job object: {}", error)) + })?; + + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const _, + std::mem::size_of::() as u32, + ) + .map_err(|error| { + RuntimeError::Internal(format!("Failed to set job object information: {}", error)) + })?; + + Ok(Self { handle }) + } + } + + pub fn assign_process(&self, pid: u32) -> Result<()> { + unsafe { + let process_handle = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) + .map_err(|error| { + RuntimeError::Internal(format!("Failed to open process {}: {}", pid, error)) + })?; + + AssignProcessToJobObject(self.handle, process_handle).map_err(|error| { + RuntimeError::Internal(format!("Failed to assign process to job: {}", error)) + })?; + + let _ = CloseHandle(process_handle); + Ok(()) + } + } +} + +#[cfg(target_os = "windows")] +impl Drop for JobHandle { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.handle); + } + } +} + +#[cfg(target_os = "windows")] +unsafe impl Send for JobHandle {} +#[cfg(target_os = "windows")] +unsafe impl Sync for JobHandle {} + +pub struct JobManager { + #[cfg(target_os = "windows")] + jobs: Arc>>>, +} + +impl JobManager { + pub fn new() -> Self { + Self { + #[cfg(target_os = "windows")] + jobs: Arc::new(RwLock::new(HashMap::new())), + } + } + + #[cfg(target_os = "windows")] + pub async fn create_and_assign(&self, env_uuid: &str, pid: u32) -> Result<()> { + let job = JobHandle::create()?; + job.assign_process(pid)?; + + let mut jobs = self.jobs.write().await; + jobs.insert(env_uuid.to_string(), Arc::new(job)); + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + pub async fn create_and_assign(&self, _env_uuid: &str, _pid: u32) -> Result<()> { + Ok(()) + } + + #[cfg(target_os = "windows")] + pub async fn remove(&self, env_uuid: &str) { + let mut jobs = self.jobs.write().await; + jobs.remove(env_uuid); + } + + #[cfg(not(target_os = "windows"))] + pub async fn remove(&self, _env_uuid: &str) {} + + #[cfg(target_os = "windows")] + pub async fn clear_all(&self) { + let mut jobs = self.jobs.write().await; + jobs.clear(); + } + + #[cfg(not(target_os = "windows"))] + pub async fn clear_all(&self) {} +} + +impl Default for JobManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs b/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs new file mode 100644 index 00000000..9536b210 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/kernel/launcher.rs @@ -0,0 +1,784 @@ +use super::cdp::CdpEndpointManager; +use super::job::JobManager; +use super::types::{ + BatchLaunchResult, CdpEndpointResponse, EnvironmentStartRequest, RpaTabCloseResult, + RpaTabSelection, RpaTabsSnapshot, WindowBoundsRequest, +}; +use crate::app::{EventPublisher, Result, RuntimeError}; +use crate::infrastructure::diagnostics::{log_info, log_warn}; +use crate::infrastructure::eventbus::{LaunchConfig, Message, eventbus_manager}; +use crate::infrastructure::eventbus::{Topic, get_eventbus_manager}; +use crate::services::environment::{EnvironmentStatus, EnvironmentStatusManager}; +use std::fs::OpenOptions; +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::process::Child; + +const BROWSER_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const CDP_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(serde::Serialize)] +struct RpaCommandPayload<'a> { + action: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + position: Option, +} + +pub async fn launch_browser( + request: EnvironmentStartRequest, + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, + events: EventPublisher, +) -> Result { + let env_id = request.env_uuid.trim().to_string(); + if matches!( + status_manager.get_status(&env_id).await, + Some( + EnvironmentStatus::Initializing + | EnvironmentStatus::Starting + | EnvironmentStatus::Running + | EnvironmentStatus::Stopping + ) + ) { + return Err(RuntimeError::InvalidState(format!( + "environment {} is already active", + env_id + ))); + } + status_manager.set_status(&env_id, EnvironmentStatus::Initializing).await; + status_manager.set_status(&env_id, EnvironmentStatus::Starting).await; + + let path = Path::new(&request.exe_path); + if !path.exists() { + let error = RuntimeError::Internal("可执行文件不存在".into()); + fail_launch( + &env_id, + "executable_check", + &error, + cdp_endpoint_manager, + job_manager, + status_manager, + events, + ) + .await; + return Err(error); + } + let work_dir = match path.parent() { + Some(work_dir) => work_dir, + None => { + let error = RuntimeError::Internal("无法获取可执行文件所在目录".into()); + fail_launch( + &env_id, + "executable_check", + &error, + cdp_endpoint_manager, + job_manager, + status_manager, + events, + ) + .await; + return Err(error); + } + }; + + let cdp_port = match cdp_endpoint_manager.allocate_port(&env_id).await { + Ok(port) => port, + Err(error) => { + fail_launch( + &env_id, + "cdp_port_allocation", + &error, + cdp_endpoint_manager, + job_manager, + status_manager, + events, + ) + .await; + return Err(error); + } + }; + + let launch_config = LaunchConfig { + env_uuid: env_id.clone(), + user_data_dir: request.user_data_dir.clone(), + proxy: request.proxy.clone(), + kernel_version: None, + extensions: None, + custom_flags: None, + cookies: request.cookies.clone(), + urls: request.urls.clone(), + fingerprint_config: request.fingerprint_config.clone(), + accounts: request.accounts.clone(), + }; + + let mut server_ready = eventbus_manager().start_server(env_id.clone(), Some(launch_config)); + + let browser = spawn_browser_process( + &request.exe_path, + work_dir, + &env_id, + &request.user_data_dir, + cdp_port, + request.display_id.as_deref(), + request.window_position.as_deref(), + request.window_size.as_deref(), + request.extension_dirs.as_ref(), + job_manager.clone(), + ) + .await; + + let browser = match browser { + Ok(browser) => browser, + Err(error) => { + fail_launch( + &env_id, + "process_spawn", + &error, + cdp_endpoint_manager.clone(), + job_manager.clone(), + status_manager.clone(), + events.clone(), + ) + .await; + return Err(error); + } + }; + + let browser = match wait_for_browser_ready(&env_id, browser, &mut server_ready).await { + Ok(browser) => browser, + Err(error) => { + fail_launch( + &env_id, + "eventbus_handshake", + &error, + cdp_endpoint_manager.clone(), + job_manager.clone(), + status_manager.clone(), + events.clone(), + ) + .await; + return Err(error); + } + }; + + let (mut browser, browser_ws_url) = match wait_for_cdp_ready(&env_id, cdp_port, browser).await { + Ok(ready) => ready, + Err(error) => { + fail_launch( + &env_id, + "cdp_ready", + &error, + cdp_endpoint_manager.clone(), + job_manager.clone(), + status_manager.clone(), + events.clone(), + ) + .await; + return Err(error); + } + }; + + status_manager.set_status(&env_id, EnvironmentStatus::Running).await; + + let watched_env_id = env_id.clone(); + tokio::spawn(async move { + match browser.wait().await { + Ok(status) => log_info( + "kernel", + format!( + "Browser process exited for environment {}: {}", + watched_env_id, status + ), + ), + Err(error) => log_warn( + "kernel", + format!( + "Failed to wait for browser process for environment {}: {}", + watched_env_id, error + ), + ), + } + }); + + log_info( + "kernel", + format!("Browser ready for environment {}", env_id), + ); + let _ = events.emit( + "environment.launch_ready", + &serde_json::json!({ + "env_uuid": env_id, + "cdp_port": cdp_port, + }), + ); + + let mut endpoint = cdp_endpoint_manager + .get_endpoint(&request.env_uuid) + .await + .ok_or_else(|| RuntimeError::Internal("failed to resolve cdp endpoint".into()))?; + endpoint.browser_ws_url = Some(browser_ws_url); + + Ok(CdpEndpointResponse { + env_uuid: endpoint.env_uuid, + host: endpoint.host, + port: endpoint.port, + version_url: endpoint.version_url, + list_url: endpoint.list_url, + browser_ws_url: endpoint.browser_ws_url, + }) +} + +async fn wait_for_cdp_ready( + env_id: &str, + cdp_port: u16, + mut browser: Child, +) -> Result<(Child, String)> { + let version_url = format!("http://127.0.0.1:{}/json/version", cdp_port); + let client = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .build() + .map_err(|error| RuntimeError::Internal(format!("failed to build CDP client: {error}")))?; + let deadline = tokio::time::Instant::now() + CDP_STARTUP_TIMEOUT; + + loop { + if let Some(status) = browser.try_wait().map_err(|error| { + RuntimeError::Internal(format!( + "failed checking browser process for environment {}: {}", + env_id, error + )) + })? { + return Err(RuntimeError::Internal(format!( + "browser process exited before CDP became ready for environment {}: {}", + env_id, status + ))); + } + + if let Ok(response) = client.get(&version_url).send().await { + if response.status().is_success() { + if let Ok(payload) = response.json::().await { + if let Some(ws_url) = + payload.get("webSocketDebuggerUrl").and_then(|value| value.as_str()) + { + return Ok((browser, ws_url.to_string())); + } + } + } + } + + if tokio::time::Instant::now() >= deadline { + return Err(RuntimeError::Internal(format!( + "CDP endpoint {} did not become ready within {} seconds for environment {}", + version_url, + CDP_STARTUP_TIMEOUT.as_secs(), + env_id + ))); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn fail_launch( + env_id: &str, + stage: &str, + error: &RuntimeError, + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, + events: EventPublisher, +) { + job_manager.remove(env_id).await; + cdp_endpoint_manager.remove(env_id).await; + status_manager.set_status(env_id, EnvironmentStatus::Error).await; + let _ = events.emit( + "environment.launch_failed", + &serde_json::json!({ + "env_uuid": env_id, + "stage": stage, + "error": error.to_string(), + }), + ); +} + +async fn wait_for_browser_ready( + env_id: &str, + mut browser: Child, + server_ready: &mut tokio::sync::mpsc::Receiver>, +) -> Result { + let timeout = tokio::time::sleep(BROWSER_STARTUP_TIMEOUT); + tokio::pin!(timeout); + + tokio::select! { + result = server_ready.recv() => { + match result { + Some(Ok(())) => Ok(browser), + Some(Err(error)) => Err(RuntimeError::EventBus(error)), + None => Err(RuntimeError::Internal(format!( + "eventbus startup channel closed for environment {}", + env_id + ))), + } + } + status = browser.wait() => { + let status = status + .map_err(|error| RuntimeError::Internal(format!( + "failed waiting for browser process for environment {}: {}", + env_id, error + )))?; + Err(RuntimeError::Internal(format!( + "browser process exited before handshake for environment {}: {}", + env_id, status + ))) + } + _ = &mut timeout => { + Err(RuntimeError::Internal(format!( + "browser handshake timed out after {} seconds for environment {}", + BROWSER_STARTUP_TIMEOUT.as_secs(), + env_id + ))) + } + } +} + +async fn spawn_browser_process( + exe_path: &str, + work_dir: &Path, + env_id: &str, + user_data_dir: &str, + cdp_port: u16, + display_id: Option<&str>, + window_position: Option<&str>, + window_size: Option<&str>, + extension_dirs: Option<&Vec>, + job_manager: Arc, +) -> Result { + let mut args = vec![ + format!("--simprint-env-id={}", env_id), + format!("--user-data-dir={}", user_data_dir), + format!("--remote-debugging-port={}", cdp_port), + "--remote-allow-origins=*".to_string(), + "--disable-skia-graphite".to_string(), + "--enable-logging=stderr".to_string(), + ]; + + if cfg!(debug_assertions) { + args.push("--v=1".to_string()); + } + + if let Some(id) = display_id { + args.push(format!("--simprint-display-id={}", id)); + } + if let Some(position) = window_position { + args.push(format!("--window-position={}", position)); + } + if let Some(size) = window_size { + args.push(format!("--window-size={}", size)); + } + if let Some(dirs) = extension_dirs { + if !dirs.is_empty() { + args.push(format!("--load-extension={}", dirs.join(","))); + log_info( + "kernel", + format!("Loading {} extensions: {}", dirs.len(), dirs.join(", ")), + ); + } + } + + let mut command = tokio::process::Command::new(exe_path); + command.current_dir(work_dir).args(&args).stdin(Stdio::null()); + + let browser_log_path = Path::new(user_data_dir).join("simprint-browser.log"); + match OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&browser_log_path) + { + Ok(stdout) => match stdout.try_clone() { + Ok(stderr) => { + command.stdout(Stdio::from(stdout)).stderr(Stdio::from(stderr)); + } + Err(error) => { + log_warn( + "kernel", + format!("Failed to clone browser log file: {}", error), + ); + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + }, + Err(error) => { + log_warn( + "kernel", + format!( + "Failed to open browser log {}: {}", + browser_log_path.display(), + error + ), + ); + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + + #[cfg(target_os = "windows")] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + command.creation_flags(CREATE_NO_WINDOW); + } + + #[cfg(not(target_os = "windows"))] + { + use std::fs; + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(exe_path) + .map_err(|error| RuntimeError::Internal(error.to_string()))? + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(exe_path, perms) + .map_err(|error| RuntimeError::Internal(error.to_string()))?; + } + + let mut child = command.spawn().map_err(|error| RuntimeError::Internal(error.to_string()))?; + let pid = child.id().ok_or_else(|| { + RuntimeError::Internal(format!( + "browser process for environment {} has no pid", + env_id + )) + })?; + + if let Err(error) = job_manager.create_and_assign(env_id, pid).await { + let _ = child.kill().await; + let _ = child.wait().await; + return Err(error); + } + + log_info( + "kernel", + format!( + "Browser process spawned for environment {} with pid {}", + env_id, pid + ), + ); + Ok(child) +} + +pub async fn stop_environment( + env_uuid: String, + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, + events: EventPublisher, +) -> Result<()> { + let env_id = env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + manager.disconnect(&env_id).await?; + job_manager.remove(&env_id).await; + cdp_endpoint_manager.remove(&env_id).await; + status_manager.set_status(&env_id, EnvironmentStatus::Stopped).await; + let _ = events.emit( + "environment.stopped", + &serde_json::json!({ "env_uuid": env_id }), + ); + Ok(()) +} + +pub async fn refresh_proxy( + env_uuid: String, + proxy: Option, + events: EventPublisher, +) -> Result<()> { + let env_id = env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + let proxy_payload = match proxy { + Some(proxy) => serde_json::to_vec(&proxy) + .map_err(|error| RuntimeError::Serialization(error.to_string()))?, + None => b"null".to_vec(), + }; + + manager.send_event(&env_id, Topic::ProxySet, proxy_payload).await?; + let _ = events.emit( + "environment.proxy_refreshed", + &serde_json::json!({ "env_uuid": env_id }), + ); + Ok(()) +} + +pub async fn set_window_bounds(request: WindowBoundsRequest, events: EventPublisher) -> Result<()> { + let env_id = request.env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + let payload = + encode_window_bounds_payload(request.x, request.y, request.width, request.height)?; + let message = Message::event(Topic::WindowSetBounds, payload); + manager.send(&env_id, &message).await?; + + let _ = events.emit( + "environment.window_bounds_updated", + &serde_json::json!({ + "env_uuid": env_id, + "x": request.x, + "y": request.y, + "width": request.width, + "height": request.height, + }), + ); + Ok(()) +} + +pub async fn get_connected_environments() -> Result> { + if let Some(manager) = get_eventbus_manager() { + Ok(manager.connected_envs().await) + } else { + Ok(vec![]) + } +} + +pub async fn get_cdp_endpoint( + env_uuid: String, + cdp_endpoint_manager: Arc, +) -> Result> { + let env_id = env_uuid.trim().to_string(); + Ok(cdp_endpoint_manager + .get_endpoint(&env_id) + .await + .map(|endpoint| CdpEndpointResponse { + env_uuid: endpoint.env_uuid, + host: endpoint.host, + port: endpoint.port, + version_url: endpoint.version_url, + list_url: endpoint.list_url, + browser_ws_url: endpoint.browser_ws_url, + })) +} + +pub async fn list_rpa_tabs(env_uuid: String) -> Result { + let env_id = env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + let response = manager + .send_request( + &env_id, + Topic::RpaCommand, + encode_rpa_command("list_tabs", None)?, + ) + .await?; + + decode_rpa_response::(response) +} + +pub async fn select_rpa_tab(env_uuid: String, position: u32) -> Result { + let env_id = env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + let response = manager + .send_request( + &env_id, + Topic::RpaCommand, + encode_rpa_command("select_tab", Some(position))?, + ) + .await?; + + decode_rpa_response::(response) +} + +pub async fn close_rpa_tab(env_uuid: String, position: u32) -> Result { + let env_id = env_uuid.trim().to_string(); + let manager = eventbus_manager(); + + if !manager.is_connected(&env_id).await { + return Err(RuntimeError::Internal(format!("环境 {} 未连接", env_id))); + } + + let response = manager + .send_request( + &env_id, + Topic::RpaCommand, + encode_rpa_command("close_tab", Some(position))?, + ) + .await?; + + decode_rpa_response::(response) +} + +pub async fn batch_launch_environments( + requests: Vec, + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, + events: EventPublisher, +) -> Result> { + let tasks: Vec<_> = requests + .into_iter() + .enumerate() + .map(|(index, request)| { + let env_uuid = request.env_uuid.clone(); + let cdp_endpoint_manager = cdp_endpoint_manager.clone(); + let job_manager = job_manager.clone(); + let status_manager = status_manager.clone(); + let events = events.clone(); + + tokio::spawn(async move { + let delay = (index as u64) * 50 + (rand::random::() % 200); + tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; + + let result = launch_browser( + request, + cdp_endpoint_manager, + job_manager, + status_manager, + events, + ) + .await; + + BatchLaunchResult { + env_uuid, + success: result.is_ok(), + error: result.err().map(|error| error.to_string()), + } + }) + }) + .collect(); + + let mut results = Vec::new(); + for task in tasks { + if let Ok(result) = task.await { + results.push(result); + } + } + + Ok(results) +} + +fn encode_window_bounds_payload(x: i32, y: i32, width: i32, height: i32) -> Result> { + if width <= 0 || height <= 0 { + return Err(RuntimeError::Internal("窗口宽高必须大于 0".into())); + } + + let mut payload = Vec::with_capacity(16); + payload.extend_from_slice(&x.to_le_bytes()); + payload.extend_from_slice(&y.to_le_bytes()); + payload.extend_from_slice(&width.to_le_bytes()); + payload.extend_from_slice(&height.to_le_bytes()); + Ok(payload) +} + +fn encode_rpa_command(action: &str, position: Option) -> Result> { + serde_json::to_vec(&RpaCommandPayload { action, position }) + .map_err(|error| RuntimeError::Serialization(error.to_string())) +} + +fn decode_rpa_response(response: Message) -> Result +where + T: serde::de::DeserializeOwned, +{ + if response.error_code != 0 { + return Err(RuntimeError::Internal(read_rpa_error_message( + &response.data, + ))); + } + + serde_json::from_slice(&response.data) + .map_err(|error| RuntimeError::Serialization(error.to_string())) +} + +fn read_rpa_error_message(data: &[u8]) -> String { + #[derive(serde::Deserialize)] + struct ErrorPayload { + message: Option, + } + + serde_json::from_slice::(data) + .ok() + .and_then(|payload| payload.message) + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| "RPA_COMMAND_FAILED".to_string()) +} + +#[cfg(test)] +mod tests { + use super::encode_window_bounds_payload; + + #[test] + fn window_bounds_payload_is_little_endian_i32_sequence() { + let payload = encode_window_bounds_payload(10, 20, 1280, 720).unwrap(); + + assert_eq!(payload.len(), 16); + assert_eq!(i32::from_le_bytes(payload[0..4].try_into().unwrap()), 10); + assert_eq!(i32::from_le_bytes(payload[4..8].try_into().unwrap()), 20); + assert_eq!(i32::from_le_bytes(payload[8..12].try_into().unwrap()), 1280); + assert_eq!(i32::from_le_bytes(payload[12..16].try_into().unwrap()), 720); + } + + #[test] + fn window_bounds_payload_rejects_non_positive_size() { + assert!(encode_window_bounds_payload(0, 0, 0, 720).is_err()); + assert!(encode_window_bounds_payload(0, 0, 1280, -1).is_err()); + } +} + +pub async fn batch_stop_environments( + env_uuids: Vec, + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, + events: EventPublisher, +) -> Result> { + let tasks: Vec<_> = env_uuids + .into_iter() + .map(|env_uuid| { + let cdp_endpoint_manager = cdp_endpoint_manager.clone(); + let job_manager = job_manager.clone(); + let status_manager = status_manager.clone(); + let events = events.clone(); + tokio::spawn(async move { + let result = stop_environment( + env_uuid.clone(), + cdp_endpoint_manager, + job_manager, + status_manager, + events, + ) + .await; + + BatchLaunchResult { + env_uuid, + success: result.is_ok(), + error: result.err().map(|error| error.to_string()), + } + }) + }) + .collect(); + + let mut results = Vec::new(); + for task in tasks { + if let Ok(result) = task.await { + results.push(result); + } + } + + Ok(results) +} diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/mod.rs b/src-tauri/crates/runtime/src/services/environment/kernel/mod.rs new file mode 100644 index 00000000..c5846238 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/kernel/mod.rs @@ -0,0 +1,226 @@ +pub mod cdp; +pub mod job; +pub mod launcher; +pub mod types; + +use super::status_manager::EnvironmentStatusManager; +use crate::app::{EventPublisher, Result}; +use cdp::CdpEndpointManager; +use job::JobManager; +use launcher::{ + batch_launch_environments, batch_stop_environments, close_rpa_tab, get_cdp_endpoint, + get_connected_environments, launch_browser, list_rpa_tabs, refresh_proxy, select_rpa_tab, + set_window_bounds, stop_environment, +}; +use std::sync::Arc; +use types::{EnvironmentCommandRequest, EnvironmentCommandResponse}; + +pub struct KernelRuntime { + cdp_endpoint_manager: Arc, + job_manager: Arc, + status_manager: Arc, +} + +impl KernelRuntime { + pub fn new(status_manager: Arc) -> Self { + Self { + cdp_endpoint_manager: Arc::new(CdpEndpointManager::new()), + job_manager: Arc::new(JobManager::new()), + status_manager, + } + } + + pub async fn execute( + &self, + command: EnvironmentCommandRequest, + events: EventPublisher, + ) -> Result { + match command { + EnvironmentCommandRequest::StartEnvironment { request } => { + let endpoint = launch_browser( + request, + self.cdp_endpoint_manager.clone(), + self.job_manager.clone(), + self.status_manager.clone(), + events, + ) + .await?; + Ok(EnvironmentCommandResponse::Started { endpoint }) + } + EnvironmentCommandRequest::BatchStartEnvironments { requests } => { + let results = batch_launch_environments( + requests, + self.cdp_endpoint_manager.clone(), + self.job_manager.clone(), + self.status_manager.clone(), + events, + ) + .await?; + Ok(EnvironmentCommandResponse::BatchLaunchResults { results }) + } + EnvironmentCommandRequest::StopEnvironment { env_uuid } => { + stop_environment( + env_uuid, + self.cdp_endpoint_manager.clone(), + self.job_manager.clone(), + self.status_manager.clone(), + events, + ) + .await?; + Ok(EnvironmentCommandResponse::Ack) + } + EnvironmentCommandRequest::BatchStopEnvironments { env_uuids } => { + let results = batch_stop_environments( + env_uuids, + self.cdp_endpoint_manager.clone(), + self.job_manager.clone(), + self.status_manager.clone(), + events, + ) + .await?; + Ok(EnvironmentCommandResponse::BatchLaunchResults { results }) + } + EnvironmentCommandRequest::RefreshProxy { env_uuid, proxy } => { + refresh_proxy(env_uuid, proxy, events).await?; + Ok(EnvironmentCommandResponse::Ack) + } + EnvironmentCommandRequest::SetWindowBounds { request } => { + set_window_bounds(request, events).await?; + Ok(EnvironmentCommandResponse::Ack) + } + EnvironmentCommandRequest::GetConnectedEnvironments => { + let env_ids = get_connected_environments().await?; + Ok(EnvironmentCommandResponse::ConnectedEnvironments { env_ids }) + } + EnvironmentCommandRequest::GetCdpEndpoint { env_uuid } => { + let endpoint = + get_cdp_endpoint(env_uuid, self.cdp_endpoint_manager.clone()).await?; + Ok(EnvironmentCommandResponse::CdpEndpoint { endpoint }) + } + EnvironmentCommandRequest::ListRpaTabs { env_uuid } => { + let snapshot = list_rpa_tabs(env_uuid).await?; + Ok(EnvironmentCommandResponse::RpaTabsSnapshot { snapshot }) + } + EnvironmentCommandRequest::SelectRpaTab { env_uuid, position } => { + let selection = select_rpa_tab(env_uuid, position).await?; + Ok(EnvironmentCommandResponse::RpaTabSelected { selection }) + } + EnvironmentCommandRequest::CloseRpaTab { env_uuid, position } => { + let result = close_rpa_tab(env_uuid, position).await?; + Ok(EnvironmentCommandResponse::RpaTabClosed { result }) + } + EnvironmentCommandRequest::GetEnvironmentStatus { env_uuid } => { + let status = self.status_manager.get_status(&env_uuid).await; + Ok(EnvironmentCommandResponse::Status { status }) + } + EnvironmentCommandRequest::GetAllEnvironmentStatuses => { + let statuses = self.status_manager.get_all_statuses().await; + Ok(EnvironmentCommandResponse::AllStatuses { statuses }) + } + } + } + + pub async fn handle_browser_disconnect(&self, env_uuid: &str, events: EventPublisher) { + self.job_manager.remove(env_uuid).await; + self.cdp_endpoint_manager.remove(env_uuid).await; + self.status_manager.set_stopped_unless_error(env_uuid).await; + let _ = events.emit( + "environment.browser_disconnected", + &serde_json::json!({ "env_uuid": env_uuid }), + ); + } + + pub async fn clear_all(&self) { + self.job_manager.clear_all().await; + self.cdp_endpoint_manager.clear_all().await; + self.status_manager.clear().await; + } + + pub async fn get_connected_env_count(&self) -> usize { + match crate::infrastructure::eventbus::get_eventbus_manager() { + Some(manager) => manager.connected_env_count().await, + None => 0, + } + } + + pub fn status_manager(&self) -> Arc { + self.status_manager.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::event_channel; + use crate::services::environment::EnvironmentStatus; + + #[tokio::test] + async fn status_queries_return_runtime_environment_statuses() { + let status_manager = Arc::new(EnvironmentStatusManager::new()); + status_manager.set_status("env-1", EnvironmentStatus::Running).await; + status_manager.set_status("env-2", EnvironmentStatus::Stopped).await; + + let runtime = KernelRuntime::new(status_manager); + let (events, _rx) = event_channel(); + + let response = runtime + .execute( + EnvironmentCommandRequest::GetEnvironmentStatus { + env_uuid: "env-1".into(), + }, + events.clone(), + ) + .await + .unwrap(); + match response { + EnvironmentCommandResponse::Status { status } => { + assert_eq!(status, Some(EnvironmentStatus::Running)); + } + other => panic!("unexpected response: {:?}", other), + } + + let response = runtime + .execute(EnvironmentCommandRequest::GetAllEnvironmentStatuses, events) + .await + .unwrap(); + match response { + EnvironmentCommandResponse::AllStatuses { statuses } => { + assert_eq!(statuses.get("env-1"), Some(&EnvironmentStatus::Running)); + assert_eq!(statuses.get("env-2"), Some(&EnvironmentStatus::Stopped)); + } + other => panic!("unexpected response: {:?}", other), + } + } + + #[tokio::test] + async fn browser_disconnect_marks_environment_stopped() { + let status_manager = Arc::new(EnvironmentStatusManager::new()); + status_manager.set_status("env-1", EnvironmentStatus::Running).await; + + let runtime = KernelRuntime::new(status_manager.clone()); + let (events, _rx) = event_channel(); + + runtime.handle_browser_disconnect("env-1", events).await; + + assert_eq!( + status_manager.get_status("env-1").await, + Some(EnvironmentStatus::Stopped) + ); + } + + #[tokio::test] + async fn browser_disconnect_preserves_launch_error() { + let status_manager = Arc::new(EnvironmentStatusManager::new()); + status_manager.set_status("env-1", EnvironmentStatus::Error).await; + + let runtime = KernelRuntime::new(status_manager.clone()); + let (events, _rx) = event_channel(); + + runtime.handle_browser_disconnect("env-1", events).await; + + assert_eq!( + status_manager.get_status("env-1").await, + Some(EnvironmentStatus::Error) + ); + } +} diff --git a/src-tauri/crates/runtime/src/services/environment/kernel/types.rs b/src-tauri/crates/runtime/src/services/environment/kernel/types.rs new file mode 100644 index 00000000..18b7cbe8 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/kernel/types.rs @@ -0,0 +1,168 @@ +use crate::infrastructure::eventbus::{AccountConfig, CookieGroup, FingerprintConfig}; +use crate::services::environment::EnvironmentStatus; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserProxyAuthPayload { + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrowserProxyConfigPayload { + pub mode: String, + pub server: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub bypass_list: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnvironmentStartRequest { + pub exe_path: String, + pub env_uuid: String, + pub user_data_dir: String, + pub cookies: Option>, + pub urls: Option>, + pub proxy: Option, + pub fingerprint_config: Option, + pub accounts: Option>, + pub display_id: Option, + pub window_position: Option, + pub window_size: Option, + pub extension_dirs: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CdpEndpointResponse { + pub env_uuid: String, + pub host: String, + pub port: u16, + pub version_url: String, + pub list_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_ws_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchLaunchResult { + pub env_uuid: String, + pub success: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WindowBoundsRequest { + pub env_uuid: String, + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpaTabInfo { + pub position: u32, + pub title: String, + pub url: String, + pub active: bool, + pub target_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpaTabsSnapshot { + pub tabs: Vec, + pub active_position: Option, + pub total: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpaTabSelection { + pub position: u32, + pub target_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpaTabCloseResult { + pub closed_position: u32, + pub active_position: u32, + pub target_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum EnvironmentCommandRequest { + StartEnvironment { + request: EnvironmentStartRequest, + }, + BatchStartEnvironments { + requests: Vec, + }, + StopEnvironment { + env_uuid: String, + }, + BatchStopEnvironments { + env_uuids: Vec, + }, + RefreshProxy { + env_uuid: String, + proxy: Option, + }, + SetWindowBounds { + request: WindowBoundsRequest, + }, + GetConnectedEnvironments, + GetCdpEndpoint { + env_uuid: String, + }, + ListRpaTabs { + env_uuid: String, + }, + SelectRpaTab { + env_uuid: String, + position: u32, + }, + CloseRpaTab { + env_uuid: String, + position: u32, + }, + GetEnvironmentStatus { + env_uuid: String, + }, + GetAllEnvironmentStatuses, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum EnvironmentCommandResponse { + Ack, + Started { + endpoint: CdpEndpointResponse, + }, + ConnectedEnvironments { + env_ids: Vec, + }, + CdpEndpoint { + endpoint: Option, + }, + RpaTabsSnapshot { + snapshot: RpaTabsSnapshot, + }, + RpaTabSelected { + selection: RpaTabSelection, + }, + RpaTabClosed { + result: RpaTabCloseResult, + }, + BatchLaunchResults { + results: Vec, + }, + Status { + status: Option, + }, + AllStatuses { + statuses: HashMap, + }, +} diff --git a/src-tauri/crates/runtime/src/services/environment/mod.rs b/src-tauri/crates/runtime/src/services/environment/mod.rs new file mode 100644 index 00000000..e5055471 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/mod.rs @@ -0,0 +1,196 @@ +pub mod kernel; +pub mod status; +pub mod status_manager; + +use crate::app::{ + EventPublisher, ModuleContext, ModuleHealthSnapshot, Result, RuntimeContext, RuntimeError, + RuntimeModule, +}; +use crate::infrastructure::eventbus::{get_eventbus_manager, init_eventbus_manager}; +use crate::services::auth::AuthStateStore; +use async_trait::async_trait; +use kernel::KernelRuntime; +use kernel::types::{EnvironmentCommandRequest, EnvironmentCommandResponse}; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub use status::EnvironmentStatus; +pub use status_manager::EnvironmentStatusManager; + +#[derive(Debug, Clone, Copy)] +enum EnvironmentModulePhase { + Dormant, + RuntimeStarted, + ContextReady, + RuntimeStopped, +} + +impl EnvironmentModulePhase { + fn as_str(self) -> &'static str { + match self { + Self::Dormant => "dormant", + Self::RuntimeStarted => "runtime_started", + Self::ContextReady => "context_ready", + Self::RuntimeStopped => "runtime_stopped", + } + } +} + +struct EnvironmentModuleState { + phase: EnvironmentModulePhase, + active_environments: u32, + last_error: Option, +} + +pub struct EnvironmentRuntimeModule { + state: RwLock, + auth_state: Arc, + status_manager: Arc, + kernel_runtime: Arc, + events: RwLock>, +} + +impl EnvironmentRuntimeModule { + pub fn new(auth_state: Arc) -> Self { + let status_manager = Arc::new(EnvironmentStatusManager::new()); + Self { + state: RwLock::new(EnvironmentModuleState { + phase: EnvironmentModulePhase::Dormant, + active_environments: 0, + last_error: None, + }), + auth_state, + status_manager: status_manager.clone(), + kernel_runtime: Arc::new(KernelRuntime::new(status_manager)), + events: RwLock::new(None), + } + } + + pub async fn execute_command( + &self, + command: EnvironmentCommandRequest, + ) -> Result { + let phase = self.state.read().await.phase; + if !matches!(phase, EnvironmentModulePhase::ContextReady) { + return Err(RuntimeError::InvalidState( + "environment runtime requires initialized context".into(), + )); + } + + let events = + self.events.read().await.clone().ok_or_else(|| { + RuntimeError::InvalidState("environment runtime not started".into()) + })?; + + self.kernel_runtime.execute(command, events).await + } +} + +#[async_trait] +impl RuntimeModule for EnvironmentRuntimeModule { + fn name(&self) -> &'static str { + "environment" + } + + async fn on_runtime_start(&self, context: ModuleContext) -> Result<()> { + { + let mut events = self.events.write().await; + *events = Some(context.events.clone()); + } + let manager = init_eventbus_manager(context.events.clone()).await; + let auth_state = self.auth_state.clone(); + manager.set_auth_info_provider(move || auth_state.snapshot()).await; + let status_manager = self.status_manager.clone(); + let event_sink = context.events.clone(); + manager + .set_connection_status_handler(move |payload| { + let status_manager = status_manager.clone(); + let event_sink = event_sink.clone(); + let emitted_payload = payload.clone(); + let status_value = payload.status.clone(); + let env_id = payload.env_id.clone(); + tokio::spawn(async move { + // A pipe connection alone is not launch readiness. The launcher marks the + // environment running only after the EventBus handshake and CDP are ready. + if status_value == "disconnected" { + status_manager.set_stopped_unless_error(&env_id).await; + } + }); + + let _ = event_sink.emit("eventbus.connection_status", &emitted_payload); + }) + .await; + let kernel_runtime = self.kernel_runtime.clone(); + let events = context.events.clone(); + manager + .set_disconnect_handler(move |env_id| { + let kernel_runtime = kernel_runtime.clone(); + let events = events.clone(); + tokio::spawn(async move { + kernel_runtime.handle_browser_disconnect(&env_id, events.clone()).await; + let _ = events.emit( + "environment.disconnected", + &serde_json::json!({ "env_uuid": env_id }), + ); + }); + }) + .await; + + let mut state = self.state.write().await; + state.phase = EnvironmentModulePhase::RuntimeStarted; + state.last_error = None; + Ok(()) + } + + async fn on_context_initialize(&self, _context: RuntimeContext) -> Result<()> { + let mut state = self.state.write().await; + if !matches!(state.phase, EnvironmentModulePhase::RuntimeStarted) { + return Err(RuntimeError::InvalidState( + "environment module requires runtime_started before context init".into(), + )); + } + state.phase = EnvironmentModulePhase::ContextReady; + Ok(()) + } + + async fn on_context_destroy(&self) -> Result<()> { + if let Some(manager) = get_eventbus_manager() { + manager.disconnect_all().await; + } + self.kernel_runtime.clear_all().await; + let mut state = self.state.write().await; + state.phase = EnvironmentModulePhase::RuntimeStarted; + state.active_environments = 0; + Ok(()) + } + + async fn on_runtime_shutdown(&self) -> Result<()> { + if let Some(manager) = get_eventbus_manager() { + manager.disconnect_all().await; + } + self.kernel_runtime.clear_all().await; + { + let mut events = self.events.write().await; + *events = None; + } + let mut state = self.state.write().await; + state.phase = EnvironmentModulePhase::RuntimeStopped; + state.active_environments = 0; + Ok(()) + } + + async fn health_snapshot(&self) -> ModuleHealthSnapshot { + let state = self.state.read().await; + let connected_envs = self.kernel_runtime.get_connected_env_count().await as u32; + ModuleHealthSnapshot { + name: self.name().into(), + phase: state.phase.as_str().into(), + healthy: state.last_error.is_none(), + detail: json!({ + "active_environments": connected_envs, + "last_error": state.last_error, + }), + } + } +} diff --git a/src-tauri/crates/runtime/src/services/environment/status.rs b/src-tauri/crates/runtime/src/services/environment/status.rs new file mode 100644 index 00000000..0dc7b571 --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/status.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnvironmentStatus { + Verifying, + Downloading, + Extracting, + Ready, + Initializing, + Starting, + Running, + Stopping, + Stopped, + Error, +} diff --git a/src-tauri/crates/runtime/src/services/environment/status_manager.rs b/src-tauri/crates/runtime/src/services/environment/status_manager.rs new file mode 100644 index 00000000..05afda7e --- /dev/null +++ b/src-tauri/crates/runtime/src/services/environment/status_manager.rs @@ -0,0 +1,57 @@ +use super::status::EnvironmentStatus; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub struct EnvironmentStatusManager { + statuses: Arc>>, +} + +impl EnvironmentStatusManager { + pub fn new() -> Self { + Self { + statuses: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn set_status(&self, env_uuid: &str, status: EnvironmentStatus) { + let mut statuses = self.statuses.write().await; + statuses.insert(env_uuid.to_string(), status); + } + + pub async fn set_stopped_unless_error(&self, env_uuid: &str) -> bool { + let mut statuses = self.statuses.write().await; + if matches!(statuses.get(env_uuid), Some(EnvironmentStatus::Error)) { + return false; + } + + statuses.insert(env_uuid.to_string(), EnvironmentStatus::Stopped); + true + } + + pub async fn get_status(&self, env_uuid: &str) -> Option { + let statuses = self.statuses.read().await; + statuses.get(env_uuid).cloned() + } + + pub async fn get_all_statuses(&self) -> HashMap { + let statuses = self.statuses.read().await; + statuses.clone() + } + + pub async fn remove_status(&self, env_uuid: &str) { + let mut statuses = self.statuses.write().await; + statuses.remove(env_uuid); + } + + pub async fn clear(&self) { + let mut statuses = self.statuses.write().await; + statuses.clear(); + } +} + +impl Default for EnvironmentStatusManager { + fn default() -> Self { + Self::new() + } +} diff --git a/src-tauri/crates/runtime/src/services/mod.rs b/src-tauri/crates/runtime/src/services/mod.rs new file mode 100644 index 00000000..2a93a3cb --- /dev/null +++ b/src-tauri/crates/runtime/src/services/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod environment; +pub mod sync; diff --git a/src-tauri/crates/runtime/src/services/sync/mod.rs b/src-tauri/crates/runtime/src/services/sync/mod.rs new file mode 100644 index 00000000..5fe2cf5b --- /dev/null +++ b/src-tauri/crates/runtime/src/services/sync/mod.rs @@ -0,0 +1,284 @@ +pub mod types; + +use crate::app::{ + EventPublisher, ModuleContext, ModuleHealthSnapshot, Result, RuntimeContext, RuntimeError, + RuntimeModule, +}; +use crate::infrastructure::eventbus::{Topic, get_eventbus_manager}; +use async_trait::async_trait; +use serde_json::json; +use tokio::sync::RwLock; + +use self::types::{RunningEnvironment, SyncCommandRequest, SyncCommandResponse}; + +#[derive(Debug, Clone, Copy)] +enum SyncModulePhase { + Dormant, + RuntimeStarted, + ContextReady, + RuntimeStopped, +} + +impl SyncModulePhase { + fn as_str(self) -> &'static str { + match self { + Self::Dormant => "dormant", + Self::RuntimeStarted => "runtime_started", + Self::ContextReady => "context_ready", + Self::RuntimeStopped => "runtime_stopped", + } + } +} + +struct SyncModuleState { + phase: SyncModulePhase, + sync_running: bool, + master_env_id: Option, + slave_env_ids: Vec, + last_error: Option, +} + +pub struct SyncRuntimeModule { + state: RwLock, + events: RwLock>, +} + +impl SyncRuntimeModule { + pub fn new() -> Self { + Self { + state: RwLock::new(SyncModuleState { + phase: SyncModulePhase::Dormant, + sync_running: false, + master_env_id: None, + slave_env_ids: Vec::new(), + last_error: None, + }), + events: RwLock::new(None), + } + } + + pub async fn execute_command( + &self, + command: SyncCommandRequest, + ) -> Result { + let phase = self.state.read().await.phase; + if !matches!(phase, SyncModulePhase::ContextReady) { + return Err(RuntimeError::InvalidState( + "sync runtime requires initialized context".into(), + )); + } + + match command { + SyncCommandRequest::GetRunningEnvironments => { + let environments = self.get_running_environments().await; + Ok(SyncCommandResponse::RunningEnvironments { environments }) + } + SyncCommandRequest::StartSync { + master_env_id, + slave_env_ids, + } => { + self.start_sync(master_env_id, slave_env_ids).await?; + Ok(SyncCommandResponse::Ack) + } + SyncCommandRequest::StopSync => { + self.stop_sync().await?; + Ok(SyncCommandResponse::Ack) + } + } + } + + async fn get_running_environments(&self) -> Vec { + match get_eventbus_manager() { + Some(manager) => manager + .connected_envs() + .await + .into_iter() + .map(|uuid| RunningEnvironment { + name: uuid.clone(), + uuid, + status: "running".to_string(), + }) + .collect(), + None => Vec::new(), + } + } + + async fn start_sync(&self, master_env_id: String, slave_env_ids: Vec) -> Result<()> { + let manager = get_eventbus_manager().ok_or_else(|| { + RuntimeError::InvalidState("eventbus manager is not initialized".into()) + })?; + + manager.set_sync_state(Some(master_env_id.clone()), slave_env_ids.clone()).await; + + let _ = manager.send_event(&master_env_id, Topic::SyncRole, vec![1u8]).await; + + for slave_id in &slave_env_ids { + let _ = manager.send_event(slave_id, Topic::SyncRole, vec![2u8]).await; + } + + { + let mut state = self.state.write().await; + state.sync_running = true; + state.master_env_id = Some(master_env_id.clone()); + state.slave_env_ids = slave_env_ids.clone(); + state.last_error = None; + } + + Ok(()) + } + + async fn stop_sync(&self) -> Result<()> { + let manager = get_eventbus_manager().ok_or_else(|| { + RuntimeError::InvalidState("eventbus manager is not initialized".into()) + })?; + + let (master, slaves) = manager.get_sync_state().await; + let mut to_notify = Vec::new(); + if let Some(master_env_id) = master.clone() { + to_notify.push(master_env_id); + } + to_notify.extend(slaves.clone()); + + for env_id in &to_notify { + let _ = manager.send_event(env_id, Topic::SyncRole, vec![0u8]).await; + } + manager.set_sync_state(None, vec![]).await; + + { + let mut state = self.state.write().await; + state.sync_running = false; + state.master_env_id = None; + state.slave_env_ids.clear(); + state.last_error = None; + } + + Ok(()) + } +} + +#[async_trait] +impl RuntimeModule for SyncRuntimeModule { + fn name(&self) -> &'static str { + "sync" + } + + async fn on_runtime_start(&self, context: ModuleContext) -> Result<()> { + { + let mut events = self.events.write().await; + *events = Some(context.events.clone()); + } + let mut state = self.state.write().await; + state.phase = SyncModulePhase::RuntimeStarted; + state.sync_running = false; + state.master_env_id = None; + state.slave_env_ids.clear(); + state.last_error = None; + Ok(()) + } + + async fn on_context_initialize(&self, _context: RuntimeContext) -> Result<()> { + let mut state = self.state.write().await; + if !matches!(state.phase, SyncModulePhase::RuntimeStarted) { + return Err(RuntimeError::InvalidState( + "sync module requires runtime_started before context init".into(), + )); + } + state.phase = SyncModulePhase::ContextReady; + state.sync_running = false; + state.master_env_id = None; + state.slave_env_ids.clear(); + state.last_error = None; + Ok(()) + } + + async fn on_context_destroy(&self) -> Result<()> { + self.stop_sync().await?; + let mut state = self.state.write().await; + state.phase = SyncModulePhase::RuntimeStarted; + state.sync_running = false; + state.master_env_id = None; + state.slave_env_ids.clear(); + Ok(()) + } + + async fn on_runtime_shutdown(&self) -> Result<()> { + self.stop_sync().await?; + let mut state = self.state.write().await; + state.phase = SyncModulePhase::RuntimeStopped; + state.sync_running = false; + state.master_env_id = None; + state.slave_env_ids.clear(); + state.last_error = None; + { + let mut events = self.events.write().await; + *events = None; + } + Ok(()) + } + + async fn health_snapshot(&self) -> ModuleHealthSnapshot { + let state = self.state.read().await; + let connected_env_count = match get_eventbus_manager() { + Some(manager) => manager.connected_env_count().await, + None => 0, + }; + ModuleHealthSnapshot { + name: self.name().into(), + phase: state.phase.as_str().into(), + healthy: state.last_error.is_none(), + detail: json!({ + "sync_running": state.sync_running, + "master_env_id": state.master_env_id, + "slave_env_ids": state.slave_env_ids, + "connected_env_count": connected_env_count, + "last_error": state.last_error, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{ModuleContext, RuntimeContextInput, event_channel}; + use crate::infrastructure::eventbus::init_eventbus_manager; + + #[tokio::test] + async fn sync_commands_update_eventbus_sync_state() { + let (events, _rx) = event_channel(); + let manager = init_eventbus_manager(events.clone()).await; + manager.set_sync_state(None, vec![]).await; + + let module = SyncRuntimeModule::new(); + module + .on_runtime_start(ModuleContext { + events: events.clone(), + }) + .await + .unwrap(); + module + .on_context_initialize(RuntimeContext::new(1, RuntimeContextInput::default())) + .await + .unwrap(); + + let response = module + .execute_command(SyncCommandRequest::StartSync { + master_env_id: "master-1".into(), + slave_env_ids: vec!["slave-1".into(), "slave-2".into()], + }) + .await + .unwrap(); + assert!(matches!(response, SyncCommandResponse::Ack)); + + let (master, slaves) = manager.get_sync_state().await; + assert_eq!(master.as_deref(), Some("master-1")); + assert_eq!(slaves, vec!["slave-1".to_string(), "slave-2".to_string()]); + + let response = module.execute_command(SyncCommandRequest::StopSync).await.unwrap(); + assert!(matches!(response, SyncCommandResponse::Ack)); + + let (master, slaves) = manager.get_sync_state().await; + assert_eq!(master, None); + assert!(slaves.is_empty()); + } +} diff --git a/src-tauri/crates/runtime/src/services/sync/types.rs b/src-tauri/crates/runtime/src/services/sync/types.rs new file mode 100644 index 00000000..675e2a5b --- /dev/null +++ b/src-tauri/crates/runtime/src/services/sync/types.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RunningEnvironment { + pub uuid: String, + pub name: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "snake_case")] +pub enum SyncCommandRequest { + GetRunningEnvironments, + StartSync { + master_env_id: String, + slave_env_ids: Vec, + }, + StopSync, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SyncCommandResponse { + Ack, + RunningEnvironments { + environments: Vec, + }, +} diff --git a/src-tauri/src/app/context.rs b/src-tauri/src/app/context.rs index 9e4a7466..01eac4f1 100644 --- a/src-tauri/src/app/context.rs +++ b/src-tauri/src/app/context.rs @@ -16,7 +16,6 @@ use crate::local_api::LocalApiManager; use crate::mcp::McpManager; use crate::services::environment::{EnvironmentPositionManager, EnvironmentStatusManager}; use crate::services::mihomo::MihomoManager; -use crate::services::runtime_updater::RuntimeUpdateService; /// 应用上下文 /// @@ -46,11 +45,8 @@ pub struct AppContext { /// Mihomo 集成管理器 pub mihomo_manager: Arc, - /// simprint-runtime 进程管理器 + /// 内嵌环境运行时管理器 pub simprint_runtime_manager: Arc, - - /// simprint-runtime 更新服务 - pub runtime_update_service: Arc, } /// 全局应用上下文实例 @@ -97,12 +93,9 @@ impl AppContext { // 初始化 Mihomo 管理器 let mihomo_manager = Arc::new(MihomoManager::new()); - // 初始化 simprint-runtime 管理器 + // 初始化内嵌环境运行时管理器 let simprint_runtime_manager = Arc::new(SimprintRuntimeManager::new()); - // 初始化 simprint-runtime 更新服务 - let runtime_update_service = Arc::new(RuntimeUpdateService::new()); - Ok(Self { config, rsa_keypair, @@ -113,7 +106,6 @@ impl AppContext { mcp_manager, mihomo_manager, simprint_runtime_manager, - runtime_update_service, }) } diff --git a/src-tauri/src/app/runtime.rs b/src-tauri/src/app/runtime.rs index 7da15801..8b22975b 100644 --- a/src-tauri/src/app/runtime.rs +++ b/src-tauri/src/app/runtime.rs @@ -1,44 +1,34 @@ -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; +use std::collections::BTreeMap; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; -use bytes::BytesMut; use tauri::{AppHandle, Emitter}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; -use tokio::sync::{Mutex, RwLock, oneshot}; +use tokio::sync::{Mutex, RwLock}; use crate::app::context::AppContext; use crate::domain::environment::EnvironmentStatus; use crate::infrastructure::runtime::{ AuthCommandRequest, AuthInfo, AuthResponse, EmptyPayload, EnvConnectionPayload, - EnvironmentCommandRequest, EnvironmentCommandResponse, EnvironmentResponse, ErrorCode, - ErrorResponse, HandshakeRequest, HandshakeResponse, InitializeContextRequest, Message, - MessageType, PROTOCOL_VERSION, RuntimeContextInput, RuntimeEventEnvelope, RuntimeIpcError, - StateResponse, SyncCommandRequest, SyncCommandResponse, SyncResponse, Topic, + EnvironmentCommandRequest, EnvironmentCommandResponse, EnvironmentResponse, + InitializeContextRequest, Message, RuntimeContextInput, StateResponse, SyncCommandRequest, + SyncCommandResponse, SyncResponse, Topic, }; -const REQUEST_TIMEOUT_SECS: u64 = 30; - +/// The environment runtime now lives in this process. The wire protocol is kept as a +/// compatibility adapter for the existing service layer, but no bytes are written to a child +/// process and no runtime executable is started or updated independently. struct ManagedRuntime { - child: Mutex, - stdin: Mutex, - pending: Mutex>>, + host: Arc, context_initialized: AtomicBool, -} - -struct ManagedRuntimeHandle { - runtime: Arc, - reader_task: tokio::task::JoinHandle<()>, - stderr_task: tokio::task::JoinHandle<()>, + context_init_lock: Mutex<()>, + event_task: tokio::task::JoinHandle<()>, } pub struct SimprintRuntimeManager { app_handle: RwLock>, - handle: Mutex>, + handle: Mutex>>, } impl SimprintRuntimeManager { @@ -50,12 +40,11 @@ impl SimprintRuntimeManager { } pub async fn set_app_handle(&self, app_handle: AppHandle) { - let mut guard = self.app_handle.write().await; - *guard = Some(app_handle); + *self.app_handle.write().await = Some(app_handle); } pub async fn is_running(&self) -> bool { - self.runtime().await.is_some() + self.handle.lock().await.is_some() } pub async fn send_environment_command( @@ -91,8 +80,9 @@ impl SimprintRuntimeManager { self.start_background().await?; self.ensure_context_ready().await?; - let auth_info = current_auth_info(); - let command = AuthCommandRequest::SetAuthState { auth_info }; + let command = AuthCommandRequest::SetAuthState { + auth_info: current_auth_info(), + }; let message = Message::request_payload(Topic::AuthCommand, &command) .map_err(runtime_err_to_string)?; let response = self.request(message).await?; @@ -105,110 +95,57 @@ impl SimprintRuntimeManager { } pub async fn stop(&self) { - let handle = self.handle.lock().await.take(); - let Some(handle) = handle else { + let runtime = self.handle.lock().await.take(); + let Some(runtime) = runtime else { return; }; - let shutdown_message = Message::request_payload(Topic::Shutdown, &EmptyPayload::default()) - .map_err(runtime_err_to_string) - .ok(); - if let Some(message) = shutdown_message { - let _ = Self::request_with_runtime(handle.runtime.clone(), message).await; - } - - { - let mut child = handle.runtime.child.lock().await; - let _ = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait()).await; - - let exited = child.try_wait().ok().flatten().is_some(); - if !exited { - let _ = child.kill().await; - let _ = child.wait().await; + if let Ok(message) = Message::request_payload(Topic::Shutdown, &EmptyPayload::default()) { + if let Err(error) = Self::request_with_runtime(runtime.clone(), message).await { + log::warn!("failed to stop embedded runtime cleanly: {}", error); } } - - handle.reader_task.abort(); - handle.stderr_task.abort(); + runtime.event_task.abort(); } async fn start_if_needed(self: &Arc) -> crate::core::error::Result<()> { - if self.runtime().await.is_some() { + let mut guard = self.handle.lock().await; + if guard.is_some() { return Ok(()); } - let executable = resolve_runtime_executable_path()?; - let mut command = Command::new(&executable); - command - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); + let (events, mut event_rx) = runtime::app::event_channel(); + let host = runtime::app::RuntimeHost::default(events); + host.start() + .await + .map_err(|error| format!("failed to start embedded runtime: {error}"))?; - #[cfg(target_os = "windows")] - { - use std::os::windows::process::CommandExt; - - const CREATE_NO_WINDOW: u32 = 0x08000000; - command.creation_flags(CREATE_NO_WINDOW); - } - - let mut child = command - .spawn() - .map_err(|error| format!("启动 simprint-runtime 失败: {}", error))?; - - let stdin = child.stdin.take().ok_or("simprint-runtime stdin 未就绪")?; - let stdout = child.stdout.take().ok_or("simprint-runtime stdout 未就绪")?; - let stderr = child.stderr.take().ok_or("simprint-runtime stderr 未就绪")?; - - let runtime = Arc::new(ManagedRuntime { - child: Mutex::new(child), - stdin: Mutex::new(stdin), - pending: Mutex::new(HashMap::new()), - context_initialized: AtomicBool::new(false), - }); - - let reader_manager = Arc::clone(self); - let reader_runtime = runtime.clone(); - let reader_task = tokio::spawn(async move { - reader_manager.run_reader_loop(reader_runtime, stdout).await; - }); - - let stderr_task = tokio::spawn(async move { - drain_stderr(stderr).await; + let manager = Arc::clone(self); + let event_task = tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + manager.handle_runtime_event(event.name, event.payload).await; + } }); - { - let mut guard = self.handle.lock().await; - *guard = Some(ManagedRuntimeHandle { - runtime: runtime.clone(), - reader_task, - stderr_task, - }); - } - - let handshake = Message::request_payload( - Topic::Handshake, - &HandshakeRequest { - protocol_version: PROTOCOL_VERSION, - client_name: "simprint".into(), - client_version: env!("CARGO_PKG_VERSION").into(), - }, - ) - .map_err(runtime_err_to_string)?; - - let response = self.request(handshake).await?; - let _: HandshakeResponse = response.payload().map_err(runtime_err_to_string)?; + *guard = Some(Arc::new(ManagedRuntime { + host, + context_initialized: AtomicBool::new(false), + context_init_lock: Mutex::new(()), + event_task, + })); + log::info!("embedded simprint runtime started"); Ok(()) } async fn ensure_context_ready(self: &Arc) -> crate::core::error::Result<()> { if !is_runtime_authenticated() { - return Err("当前未登录,无法初始化 simprint-runtime".into()); + return Err("当前未登录,无法初始化环境运行时".into()); } self.start_if_needed().await?; - let runtime = self.runtime().await.ok_or("simprint-runtime 未启动")?; + let runtime = self.runtime().await.ok_or("内嵌环境运行时未启动")?; + let _init_guard = runtime.context_init_lock.lock().await; if runtime.context_initialized.load(Ordering::SeqCst) { return Ok(()); } @@ -225,14 +162,14 @@ impl SimprintRuntimeManager { }, ) .map_err(runtime_err_to_string)?; - let response = self.request(message).await?; + let response = Self::request_with_runtime(runtime.clone(), message).await?; let _: StateResponse = response.payload().map_err(runtime_err_to_string)?; runtime.context_initialized.store(true, Ordering::SeqCst); Ok(()) } async fn request(self: &Arc, message: Message) -> crate::core::error::Result { - let runtime = self.runtime().await.ok_or("simprint-runtime 未启动")?; + let runtime = self.runtime().await.ok_or("内嵌环境运行时未启动")?; Self::request_with_runtime(runtime, message).await } @@ -240,137 +177,33 @@ impl SimprintRuntimeManager { runtime: Arc, message: Message, ) -> crate::core::error::Result { - let (tx, rx) = oneshot::channel(); - { - let mut pending = runtime.pending.lock().await; - pending.insert(message.msg_id, tx); - } - - let encoded = message.encode().map_err(runtime_err_to_string)?; - { - let mut stdin = runtime.stdin.lock().await; - if let Err(error) = stdin.write_all(&encoded).await { - let mut pending = runtime.pending.lock().await; - pending.remove(&message.msg_id); - return Err(format!("向 simprint-runtime 发送请求失败: {}", error).into()); - } - if let Err(error) = stdin.flush().await { - let mut pending = runtime.pending.lock().await; - pending.remove(&message.msg_id); - return Err(format!("刷新 simprint-runtime 请求失败: {}", error).into()); - } - } - - let response = - tokio::time::timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS), rx) - .await - .map_err(|_| "等待 simprint-runtime 响应超时")? - .map_err(|_| "simprint-runtime 响应通道已关闭")?; - - if response.error_code != ErrorCode::Success.as_i32() { - let message = response - .payload::() - .map(|payload| payload.message) - .unwrap_or_else(|_| format!("runtime error code {}", response.error_code)); - return Err(format!("simprint-runtime 请求失败: {}", message).into()); - } - - Ok(response) + let request = to_embedded_message(message); + let dispatch = runtime + .host + .handle_request(request) + .await + .map_err(|error| format!("环境运行时请求失败: {error}"))?; + Ok(from_embedded_message(dispatch.response)) } async fn runtime(&self) -> Option> { - let mut guard = self.handle.lock().await; - let Some(handle) = guard.as_ref() else { - return None; - }; - - let exited = { - let mut child = handle.runtime.child.lock().await; - child.try_wait().ok().flatten().is_some() - }; - - if exited { - let handle = guard.take().unwrap(); - handle.reader_task.abort(); - handle.stderr_task.abort(); - return None; - } - - Some(handle.runtime.clone()) + self.handle.lock().await.clone() } - async fn run_reader_loop(self: Arc, runtime: Arc, stdout: ChildStdout) { - let mut stdout = stdout; - let mut buffer = BytesMut::with_capacity(64 * 1024); - let mut temp = [0u8; 64 * 1024]; - - loop { - match crate::infrastructure::runtime::Message::try_decode(&buffer) { - Ok(Some((message, consumed))) => { - let _ = buffer.split_to(consumed); - match message.msg_type { - MessageType::Response => { - let sender = { - let mut pending = runtime.pending.lock().await; - pending.remove(&message.msg_id) - }; - if let Some(sender) = sender { - let _ = sender.send(message); - } - } - MessageType::Event if matches!(message.topic, Topic::RuntimeEvent) => { - match message.payload::() { - Ok(event) => self.handle_runtime_event(event).await, - Err(error) => { - log::warn!("failed to decode runtime event: {}", error); - } - } - } - _ => {} - } - } - Ok(None) => match stdout.read(&mut temp).await { - Ok(0) => { - log::warn!("simprint-runtime reader loop stopped: connection closed"); - break; - } - Ok(bytes_read) => { - buffer.extend_from_slice(&temp[..bytes_read]); - } - Err(error) => { - log::warn!("simprint-runtime reader loop stopped: {}", error); - break; - } - }, - Err(error) => { - log::warn!("simprint-runtime reader loop stopped: {}", error); - break; - } - } - } - } - - async fn handle_runtime_event(&self, event: RuntimeEventEnvelope) { + async fn handle_runtime_event(&self, name: String, payload: serde_json::Value) { if let Some(app_handle) = self.app_handle.read().await.clone() { - let _ = app_handle.emit(&event.name, event.payload.clone()); + let _ = app_handle.emit(&name, payload.clone()); - if event.name == "eventbus.connection_status" { - if let Ok(payload) = - serde_json::from_value::(event.payload.clone()) - { - let _ = app_handle.emit("env-connection-status", payload.clone()); + if name == "eventbus.connection_status" { + if let Ok(connection) = serde_json::from_value::(payload) { + let _ = app_handle.emit("env-connection-status", connection.clone()); if let Some(ctx) = AppContext::try_get() { - match payload.status.as_str() { - "connected" => { - ctx.env_status_manager - .set_status(&payload.env_id, EnvironmentStatus::Running) - .await; - } + match connection.status.as_str() { "disconnected" => { ctx.env_status_manager - .set_status(&payload.env_id, EnvironmentStatus::Stopped) + .set_stopped_unless_error(&connection.env_id) .await; - ctx.env_position_manager.release_position(&payload.env_id).await; + ctx.env_position_manager.release_position(&connection.env_id).await; } _ => {} } @@ -381,72 +214,80 @@ impl SimprintRuntimeManager { } if let Some(ctx) = AppContext::try_get() { - match event.name.as_str() { + match name.as_str() { "environment.stopped" | "environment.disconnected" | "environment.browser_disconnected" => { - if let Some(env_uuid) = - event.payload.get("env_uuid").and_then(|value| value.as_str()) + if let Some(env_uuid) = payload.get("env_uuid").and_then(|value| value.as_str()) { - ctx.env_status_manager - .set_status(env_uuid, EnvironmentStatus::Stopped) - .await; + ctx.env_status_manager.set_stopped_unless_error(env_uuid).await; ctx.env_position_manager.release_position(env_uuid).await; } } "environment.launch_failed" => { - if let Some(env_uuid) = - event.payload.get("env_uuid").and_then(|value| value.as_str()) + if let Some(env_uuid) = payload.get("env_uuid").and_then(|value| value.as_str()) { ctx.env_status_manager.set_status(env_uuid, EnvironmentStatus::Error).await; } } + "environment.launch_ready" => { + if let Some(env_uuid) = payload.get("env_uuid").and_then(|value| value.as_str()) + { + ctx.env_status_manager + .set_status(env_uuid, EnvironmentStatus::Running) + .await; + } + } _ => {} } } } } -fn runtime_err_to_string(error: RuntimeIpcError) -> crate::core::error::Error { - error.to_string().into() -} - -pub fn runtime_executable_path() -> crate::core::error::Result { - if !cfg!(feature = "production") { - let resource_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("resources") - .join(runtime_file_name()); - return Ok(resource_path); +fn to_embedded_message(message: Message) -> runtime::infrastructure::ipc::Message { + use runtime::infrastructure::ipc::{Message as EmbeddedMessage, MessageType, Topic}; + + let msg_type = match message.msg_type { + crate::infrastructure::runtime::MessageType::Request => MessageType::Request, + crate::infrastructure::runtime::MessageType::Response => MessageType::Response, + crate::infrastructure::runtime::MessageType::Event => MessageType::Event, + }; + + EmbeddedMessage { + msg_id: message.msg_id, + msg_type, + topic: Topic::from(u16::from(message.topic)), + error_code: message.error_code, + data: message.data, } - - let current_exe = std::env::current_exe()?; - let executable_dir = current_exe.parent().ok_or("无法确定当前可执行文件目录")?; - Ok(executable_dir.join(runtime_file_name())) } -fn resolve_runtime_executable_path() -> crate::core::error::Result { - let runtime_path = runtime_executable_path()?; - if !runtime_path.exists() { - return Err(format!( - "未找到 simprint-runtime 可执行文件: {}", - runtime_path.display() - ) - .into()); +fn from_embedded_message(message: runtime::infrastructure::ipc::Message) -> Message { + let msg_type = match message.msg_type { + runtime::infrastructure::ipc::MessageType::Request => { + crate::infrastructure::runtime::MessageType::Request + } + runtime::infrastructure::ipc::MessageType::Response => { + crate::infrastructure::runtime::MessageType::Response + } + runtime::infrastructure::ipc::MessageType::Event => { + crate::infrastructure::runtime::MessageType::Event + } + }; + + Message { + msg_id: message.msg_id, + msg_type, + topic: Topic::from(u16::from(message.topic)), + error_code: message.error_code, + data: message.data, } - - Ok(runtime_path) } -fn runtime_file_name() -> &'static str { - #[cfg(target_os = "windows")] - { - "simprint-runtime.exe" - } - - #[cfg(not(target_os = "windows"))] - { - "simprint-runtime" - } +fn runtime_err_to_string( + error: crate::infrastructure::runtime::RuntimeIpcError, +) -> crate::core::error::Error { + error.to_string().into() } fn current_auth_info() -> AuthInfo { @@ -472,18 +313,8 @@ fn is_runtime_authenticated() -> bool { crate::infrastructure::persistence::credential::is_login() } -async fn drain_stderr(stderr: ChildStderr) { - let mut reader = BufReader::new(stderr).lines(); - loop { - match reader.next_line().await { - Ok(Some(line)) => { - log::debug!("[simprint-runtime] {}", line); - } - Ok(None) => break, - Err(error) => { - log::warn!("failed to read simprint-runtime stderr: {}", error); - break; - } - } +impl Default for SimprintRuntimeManager { + fn default() -> Self { + Self::new() } } diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 55ec72cb..270c4991 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -94,10 +94,9 @@ pub fn init_simprint_runtime_background(app_handle: AppHandle) { let ctx = AppContext::get(); ctx.simprint_runtime_manager.set_app_handle(app_handle.clone()).await; - ctx.runtime_update_service.start_background(app_handle.clone()); if let Err(error) = ctx.simprint_runtime_manager.start_background().await { - log::warn!("failed to start simprint-runtime: {}", error); + log::warn!("failed to start embedded environment runtime: {}", error); } }); } diff --git a/src-tauri/src/app/splashscreen.rs b/src-tauri/src/app/splashscreen.rs index 34342c27..ffc27475 100644 --- a/src-tauri/src/app/splashscreen.rs +++ b/src-tauri/src/app/splashscreen.rs @@ -1,8 +1,23 @@ use crate::commands::updater; +use std::ffi::OsStr; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::{AppHandle, Emitter, Manager}; +const SKIP_UPDATE_ARG: &str = "--skip-update"; + +fn has_skip_update_arg(args: I) -> bool +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().any(|arg| arg.as_ref() == OsStr::new(SKIP_UPDATE_ARG)) +} + +fn should_skip_update() -> bool { + has_skip_update_arg(std::env::args_os()) +} + // 前端就绪标志 static SPLASHSCREEN_FRONTEND_READY: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -140,70 +155,82 @@ pub fn init_startup(app_handle: AppHandle) { emit_progress(&app_handle_clone, 90, "服务器连接成功", None); // 步骤4.1: 检查并处理更新(自动检查、下载、安装) - emit_progress(&app_handle_clone, 92, "检查更新...", Some("update-check")); - let updates_available = match updater::check_updates(app_handle_clone.clone()).await { - Ok(result) => result.has_updates, - Err(e) => { - log::error!("Update check failed: {}", e); - emit_progress( - &app_handle_clone, - 92, - "检查更新失败,继续启动", - Some("update-check"), - ); - false - } - }; - - if updates_available { + // 跳过参数只在更新入口消费,不传入更新服务、下载器或安装器。 + if should_skip_update() { + log::info!("Skipping startup update flow because {SKIP_UPDATE_ARG} was provided"); emit_progress( &app_handle_clone, - 94, - "检测到更新,开始下载...", - Some("update-download"), + 92, + "已跳过更新检查", + Some("update-check"), ); - match updater::download_updates(app_handle_clone.clone(), None).await { - Ok(download_result) => { - if download_result.success_count > 0 { - emit_progress( - &app_handle_clone, - 96, - "下载完成,准备安装", - Some("update-install"), - ); - // 触发安装并退出(updater.exe 负责后续重启) - if let Err(e) = - updater::start_update_install(app_handle_clone.clone()).await - { - log::error!("Update installation start failed: {}", e); + emit_status_complete(&app_handle_clone, "update-check"); + } else { + emit_progress(&app_handle_clone, 92, "检查更新...", Some("update-check")); + let updates_available = match updater::check_updates(app_handle_clone.clone()).await { + Ok(result) => result.has_updates, + Err(e) => { + log::error!("Update check failed: {}", e); + emit_progress( + &app_handle_clone, + 92, + "检查更新失败,继续启动", + Some("update-check"), + ); + false + } + }; + + if updates_available { + emit_progress( + &app_handle_clone, + 94, + "检测到更新,开始下载...", + Some("update-download"), + ); + match updater::download_updates(app_handle_clone.clone(), None).await { + Ok(download_result) => { + if download_result.success_count > 0 { emit_progress( &app_handle_clone, 96, - "安装启动失败,继续当前版本", + "下载完成,准备安装", Some("update-install"), ); + // 触发安装并退出(updater.exe 负责后续重启) + if let Err(e) = + updater::start_update_install(app_handle_clone.clone()).await + { + log::error!("Update installation start failed: {}", e); + emit_progress( + &app_handle_clone, + 96, + "安装启动失败,继续当前版本", + Some("update-install"), + ); + } + // 无论安装启动是否成功,都不再继续创建主窗口,交由 updater.exe 或用户重启 + return; + } else { + log::warn!("Update download failed, continuing with current version"); + emit_progress( + &app_handle_clone, + 94, + "下载失败,继续启动当前版本", + Some("update-download"), + ); } - // 无论安装启动是否成功,都不再继续创建主窗口,交由 updater.exe 或用户重启 - return; - } else { - log::warn!("Update download failed, continuing with current version"); + } + Err(e) => { + log::error!("Update download error: {}", e); emit_progress( &app_handle_clone, 94, - "下载失败,继续启动当前版本", + "下载更新失败,继续启动当前版本", Some("update-download"), ); } } - Err(e) => { - log::error!("Update download error: {}", e); - emit_progress( - &app_handle_clone, - 94, - "下载更新失败,继续启动当前版本", - Some("update-download"), - ); - } } } @@ -223,3 +250,22 @@ pub fn init_startup(app_handle: AppHandle) { emit_ready(&app_handle_clone); }); } + +#[cfg(test)] +mod tests { + use super::has_skip_update_arg; + + #[test] + fn detects_skip_update_argument() { + assert!(has_skip_update_arg(["simprint.exe", "--skip-update"])); + } + + #[test] + fn does_not_treat_other_arguments_as_skip_update() { + assert!(!has_skip_update_arg([ + "simprint.exe", + "--skip-update-check", + "simprint://open" + ])); + } +} diff --git a/src-tauri/src/core/config/types.rs b/src-tauri/src/core/config/types.rs index e91ab96b..ba838ff5 100644 --- a/src-tauri/src/core/config/types.rs +++ b/src-tauri/src/core/config/types.rs @@ -20,7 +20,6 @@ pub struct ServerConfig { pub struct UpdaterConfig { pub check_url: String, pub latest_json_url: String, - pub runtime_latest_json_url: String, /// 下载的临时目录(可选)。 /// - 若为空:默认使用统一根目录下的 `updates` diff --git a/src-tauri/src/core/config/validator.rs b/src-tauri/src/core/config/validator.rs index 2e975a8b..3bcf05d8 100644 --- a/src-tauri/src/core/config/validator.rs +++ b/src-tauri/src/core/config/validator.rs @@ -73,19 +73,5 @@ fn validate_updater_config(config: &AppConfig) -> Result<()> { )); } - if config.updater.runtime_latest_json_url.is_empty() { - return Err(Error::ConfigValidationFailed( - "updater.runtime_latest_json_url cannot be empty".to_string(), - )); - } - - if !config.updater.runtime_latest_json_url.starts_with("http://") - && !config.updater.runtime_latest_json_url.starts_with("https://") - { - return Err(Error::ConfigValidationFailed( - "updater.runtime_latest_json_url must start with http:// or https://".to_string(), - )); - } - Ok(()) } diff --git a/src-tauri/src/infrastructure/http/encryption/aes.rs b/src-tauri/src/infrastructure/http/encryption/aes.rs index b2bfd510..21adfa4d 100644 --- a/src-tauri/src/infrastructure/http/encryption/aes.rs +++ b/src-tauri/src/infrastructure/http/encryption/aes.rs @@ -161,7 +161,7 @@ mod tests { /// 测试加密流程 #[test] fn test_login_payload_encrypt() { - use rsa::pkcs1::DecodeRsaPublicKey; + use crate::infrastructure::http::encryption::RsaSecret; let secret = AesSecret::new(); let key = secret.get_key_as_base64(); @@ -184,19 +184,13 @@ mod tests { let json_bytes = serde_json::to_vec(&payload).expect("Serialization failed"); let encrypted = secret.encrypt(&json_bytes).expect("Encryption failed"); - // 下面连续的代码对应: crate::infrastructure::http::encryption::rsa::get_rsa_secret_instance() - let public_key_str = - std::fs::read("../../assets/secret/public_key.pem").expect("读取公钥失败"); - let public_key = String::from_utf8(public_key_str).expect("转换公钥失败"); - let public_key = - rsa::RsaPublicKey::from_pkcs1_pem(&public_key).expect("Failed to parse public key"); - let mut rng = rsa::rand_core::OsRng::default(); - let encrypted_data = public_key - .encrypt(&mut rng, rsa::Pkcs1v15Encrypt, key.as_bytes()) - .expect("Failed to encrypt"); - - // 通过一个非对称公钥加密key - let encrypted_key = base64::engine::general_purpose::STANDARD.encode(&encrypted_data); + // 使用测试内生成的密钥对,避免依赖仓库外部的公钥文件。 + let rsa_secret = RsaSecret::new().expect("生成 RSA 密钥对失败"); + let public_key = rsa_secret.get_public_key().expect("编码 RSA 公钥失败"); + let encrypted_key = RsaSecret::encrypt_with_public_key(key.as_bytes(), &public_key) + .expect("加密 AES 密钥失败"); + let decrypted_key = rsa_secret.decrypt(&encrypted_key).expect("解密 AES 密钥失败"); + assert_eq!(decrypted_key, key.as_bytes()); let result = serde_json::json!({ "data": encrypted, diff --git a/src-tauri/src/services/environment/kernel/runtime_bridge.rs b/src-tauri/src/services/environment/kernel/runtime_bridge.rs index ba049bc1..57a12d42 100644 --- a/src-tauri/src/services/environment/kernel/runtime_bridge.rs +++ b/src-tauri/src/services/environment/kernel/runtime_bridge.rs @@ -34,7 +34,8 @@ pub async fn launch_environment( extensions: Option>, status_emitter: Option, ) -> Result<()> { - let request = prepare_start_request( + let env_id = env_uuid.trim().to_string(); + let request = match prepare_start_request( app, exe_path, env_uuid, @@ -45,14 +46,28 @@ pub async fn launch_environment( fingerprint_config, accounts, extensions, - status_emitter, + status_emitter.clone(), ) - .await?; + .await + { + Ok(request) => request, + Err(error) => { + mark_launch_error(&env_id, status_emitter.as_ref(), &error.to_string()).await; + return Err(error); + } + }; - let response = AppContext::get() + let response = match AppContext::get() .simprint_runtime_manager .send_environment_command(EnvironmentCommandRequest::StartEnvironment { request }) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + mark_launch_error(&env_id, status_emitter.as_ref(), &error.to_string()).await; + return Err(error); + } + }; match response { EnvironmentCommandResponse::Ack | EnvironmentCommandResponse::Started { .. } => Ok(()), @@ -66,19 +81,32 @@ pub async fn batch_launch_environments( status_emitter: Option, ) -> Result> { let requests = try_join_all(launch_requests.into_iter().map(|request| { - prepare_start_request( - app.clone(), - request.exe_path, - request.env_uuid, - request.cache_path, - request.cookies, - request.urls, - request.proxy, - request.fingerprint_config, - request.accounts, - request.extensions, - status_emitter.clone(), - ) + let app = app.clone(); + let status_emitter = status_emitter.clone(); + async move { + let env_id = request.env_uuid.trim().to_string(); + match prepare_start_request( + app, + request.exe_path, + request.env_uuid, + request.cache_path, + request.cookies, + request.urls, + request.proxy, + request.fingerprint_config, + request.accounts, + request.extensions, + status_emitter.clone(), + ) + .await + { + Ok(request) => Ok(request), + Err(error) => { + mark_launch_error(&env_id, status_emitter.as_ref(), &error.to_string()).await; + Err(error) + } + } + } })) .await?; @@ -89,12 +117,44 @@ pub async fn batch_launch_environments( match response { EnvironmentCommandResponse::BatchLaunchResults { results } => { + for result in &results { + if !result.success { + mark_launch_error( + &result.env_uuid, + status_emitter.as_ref(), + result.error.as_deref().unwrap_or("浏览器启动失败"), + ) + .await; + } + } Ok(results.into_iter().map(map_batch_launch_result).collect()) } other => Err(format!("simprint-runtime 返回了非预期响应: {:?}", other).into()), } } +async fn mark_launch_error( + env_uuid: &str, + status_emitter: Option<&KernelStatusEmitter>, + message: &str, +) { + if let Some(ctx) = AppContext::try_get() { + ctx.env_status_manager.set_status(env_uuid, EnvironmentStatus::Error).await; + ctx.env_position_manager.release_position(env_uuid).await; + } + + emit_status( + status_emitter, + &Some(env_uuid.to_string()), + "", + EnvironmentStatus::Error, + Some(message), + None, + None, + None, + ); +} + pub async fn stop_environment(env_uuid: String) -> Result<()> { let response = AppContext::get() .simprint_runtime_manager diff --git a/src-tauri/src/services/environment/status_manager.rs b/src-tauri/src/services/environment/status_manager.rs index 576c88ee..2d71b3ec 100644 --- a/src-tauri/src/services/environment/status_manager.rs +++ b/src-tauri/src/services/environment/status_manager.rs @@ -27,6 +27,17 @@ impl EnvironmentStatusManager { statuses.insert(env_uuid.to_string(), status); } + /// Mark an environment stopped without hiding a launch failure that was already reported. + pub async fn set_stopped_unless_error(&self, env_uuid: &str) -> bool { + let mut statuses = self.statuses.write().await; + if matches!(statuses.get(env_uuid), Some(EnvironmentStatus::Error)) { + return false; + } + + statuses.insert(env_uuid.to_string(), EnvironmentStatus::Stopped); + true + } + /// 获取环境状态 pub async fn get_status(&self, env_uuid: &str) -> Option { let statuses = self.statuses.read().await; diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index e511fa26..72a6cdb1 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -13,6 +13,5 @@ pub mod environment; pub mod file_system; pub mod local_extensions; pub mod mihomo; -pub mod runtime_updater; pub mod updater; pub mod window; diff --git a/src-tauri/src/services/runtime_updater/mod.rs b/src-tauri/src/services/runtime_updater/mod.rs deleted file mode 100644 index 2e483aa0..00000000 --- a/src-tauri/src/services/runtime_updater/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod service; -pub mod types; - -pub use service::RuntimeUpdateService; diff --git a/src-tauri/src/services/runtime_updater/service.rs b/src-tauri/src/services/runtime_updater/service.rs deleted file mode 100644 index 627ad3be..00000000 --- a/src-tauri/src/services/runtime_updater/service.rs +++ /dev/null @@ -1,293 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use futures::StreamExt; -use reqwest::Client; -use tauri::{AppHandle, Emitter}; -use tokio::sync::Mutex; - -use crate::app::context::AppContext; -use crate::core::error::{Error, Result}; -use crate::infrastructure::runtime::PROTOCOL_VERSION; -use crate::infrastructure::updater::planner::calculate_file_hash; -use crate::infrastructure::updater::types::{InstallTask, InstallTasks}; -use crate::services::updater::PreparedUpdateInfo; -use crate::services::updater::UpdateService; - -use super::types::{PreparedRuntimeUpdate, RuntimeLatestRelease}; - -const POLL_INTERVAL: Duration = Duration::from_secs(180); -const READY_EVENT_NAME: &str = "app-update-ready"; - -#[cfg(target_os = "windows")] -const RUNTIME_RESOURCE_NAME: &str = "simprint-runtime.exe"; -#[cfg(not(target_os = "windows"))] -const RUNTIME_RESOURCE_NAME: &str = "simprint-runtime"; - -#[derive(Default)] -struct RuntimeUpdateState { - prepared: Option, -} - -pub struct RuntimeUpdateService { - started: AtomicBool, - state: Mutex, -} - -impl RuntimeUpdateService { - pub fn new() -> Self { - Self { - started: AtomicBool::new(false), - state: Mutex::new(RuntimeUpdateState::default()), - } - } - - pub fn start_background(self: &Arc, app_handle: AppHandle) { - if self.started.swap(true, Ordering::SeqCst) { - return; - } - - let service = Arc::clone(self); - tauri::async_runtime::spawn(async move { - loop { - if let Err(error) = service.poll_once(&app_handle).await { - log::warn!("runtime update poll failed: {}", error); - } - - tokio::time::sleep(POLL_INTERVAL).await; - } - }); - } - - pub async fn start_prepared_install(_app_handle: AppHandle) -> Result<()> { - let ctx = AppContext::get(); - let prepared = { - let state = ctx.runtime_update_service.state.lock().await; - state.prepared.clone() - } - .ok_or_else(|| Error::UpdateInstallFailed)?; - - if !prepared.tasks_file.exists() || !prepared.artifact_path.exists() { - return Err("待安装更新文件不存在,请等待重新下载".into()); - } - - ctx.simprint_runtime_manager.stop().await; - UpdateService::start_update_install_with_tasks_file(prepared.tasks_file).await - } - - pub async fn peek_prepared_update() -> Result> { - let ctx = AppContext::get(); - let prepared = { - let state = ctx.runtime_update_service.state.lock().await; - state.prepared.clone() - }; - - Ok(prepared.map(|prepared| PreparedUpdateInfo { - kind: "runtime".to_string(), - version: prepared.version, - restart_required: true, - })) - } - - async fn poll_once(&self, app_handle: &AppHandle) -> Result<()> { - let release = self.fetch_latest_release().await?; - let platform = release.platforms.get(Self::current_target_triple()).ok_or_else(|| { - Error::UpdateCheckFailed(format!( - "runtime latest.json 缺少平台 {}", - Self::current_target_triple() - )) - })?; - - if release.protocol_version != u64::from(PROTOCOL_VERSION) { - log::info!( - "skip runtime update because protocol_version mismatch: local={}, remote={}", - PROTOCOL_VERSION, - release.protocol_version - ); - return Ok(()); - } - - let runtime_path = crate::app::runtime::runtime_executable_path()?; - if runtime_path.exists() { - let local_hash = calculate_file_hash(&runtime_path).map_err(|error| { - Error::UpdateCheckFailed(format!("runtime 本地文件哈希计算失败: {}", error)) - })?; - if local_hash.eq_ignore_ascii_case(&platform.sha256) { - let mut state = self.state.lock().await; - state.prepared = None; - return Ok(()); - } - } - - { - let state = self.state.lock().await; - if let Some(prepared) = state.prepared.as_ref() { - if prepared.version == release.version - && prepared.tasks_file.exists() - && prepared.artifact_path.exists() - { - return Ok(()); - } - } - } - - let prepared = self.download_and_prepare(&release, platform).await?; - - { - let mut state = self.state.lock().await; - state.prepared = Some(prepared.clone()); - } - - let _ = app_handle.emit( - READY_EVENT_NAME, - PreparedUpdateInfo { - kind: "runtime".to_string(), - version: prepared.version, - restart_required: true, - }, - ); - - Ok(()) - } - - async fn fetch_latest_release(&self) -> Result { - let ctx = AppContext::get(); - let response = Client::builder() - .timeout(Duration::from_secs(30)) - .build()? - .get(&ctx.config.updater.runtime_latest_json_url) - .send() - .await?; - - let status = response.status(); - if !status.is_success() { - return Err(Error::UpdateCheckFailed(format!( - "runtime latest.json 请求失败: {}", - status - ))); - } - - response.json::().await.map_err(|error| { - Error::UpdateCheckFailed(format!("runtime latest.json 解析失败: {}", error)) - }) - } - - async fn download_and_prepare( - &self, - release: &RuntimeLatestRelease, - platform: &super::types::RuntimeLatestReleasePlatform, - ) -> Result { - if platform.url.trim().is_empty() { - return Err("runtime latest.json 未提供下载地址".into()); - } - - let runtime_dir = runtime_update_dir()?; - fs::create_dir_all(&runtime_dir)?; - - let artifact_path = - runtime_dir.join(format!("simprint-runtime-{}.download", release.version)); - let tasks_file = runtime_dir.join("runtime-update-tasks.json"); - let backup_path = runtime_dir.join(format!("simprint-runtime-{}.bak", release.version)); - - download_to_file(&platform.url, &artifact_path).await?; - - let actual_hash = calculate_file_hash(&artifact_path).map_err(|error| { - Error::UpdateCheckFailed(format!("runtime 更新包校验失败: {}", error)) - })?; - if !actual_hash.eq_ignore_ascii_case(&platform.sha256) { - return Err(Error::UpdateCheckFailed(format!( - "runtime 更新包哈希不匹配: expected={}, actual={}", - platform.sha256, actual_hash - ))); - } - - let install_tasks = InstallTasks { - tasks: vec![InstallTask { - resource_name: RUNTIME_RESOURCE_NAME.to_string(), - version: release.version.clone(), - target_path: crate::app::runtime::runtime_executable_path()? - .to_string_lossy() - .to_string(), - backup_path: Some(backup_path.to_string_lossy().to_string()), - temp_path: artifact_path.to_string_lossy().to_string(), - expected_hash: platform.sha256.clone(), - }], - }; - - let payload = serde_json::to_vec_pretty(&install_tasks).map_err(|error| { - Error::UpdateCheckFailed(format!("runtime 安装任务序列化失败: {}", error)) - })?; - fs::write(&tasks_file, payload).map_err(|error| { - Error::UpdateCheckFailed(format!("runtime 安装任务写入失败: {}", error)) - })?; - - Ok(PreparedRuntimeUpdate { - version: release.version.clone(), - artifact_path, - tasks_file, - }) - } - - fn current_target_triple() -> &'static str { - #[cfg(all(target_os = "windows", target_arch = "x86_64"))] - { - "x86_64-pc-windows-msvc" - } - - #[cfg(all(target_os = "windows", target_arch = "aarch64"))] - { - "aarch64-pc-windows-msvc" - } - - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] - { - "x86_64-unknown-linux-gnu" - } - - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] - { - "x86_64-apple-darwin" - } - - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { - "aarch64-apple-darwin" - } - } -} - -fn runtime_update_dir() -> Result { - Ok(crate::core::paths::PathManager::get_updater_dir()?.join("runtime")) -} - -async fn download_to_file(url: &str, target_path: &Path) -> Result<()> { - let client = Client::builder().timeout(Duration::from_secs(300)).build()?; - let response = client.get(url).send().await?; - - if !response.status().is_success() { - return Err(Error::UpdateCheckFailed(format!( - "runtime 更新包下载失败: {}", - response.status() - ))); - } - - let parent = target_path.parent().ok_or("无法确定 runtime 更新目录")?; - fs::create_dir_all(parent)?; - - let mut file = fs::File::create(target_path)?; - let mut stream = response.bytes_stream(); - - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|error| { - Error::UpdateCheckFailed(format!("runtime 更新包读取失败: {}", error)) - })?; - std::io::Write::write_all(&mut file, &chunk)?; - } - - std::io::Write::flush(&mut file)?; - file.sync_all()?; - Ok(()) -} diff --git a/src-tauri/src/services/runtime_updater/types.rs b/src-tauri/src/services/runtime_updater/types.rs deleted file mode 100644 index 2a13cb8d..00000000 --- a/src-tauri/src/services/runtime_updater/types.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Debug, Clone, Deserialize)] -pub struct RuntimeLatestRelease { - pub version: String, - pub protocol_version: u64, - #[serde(default)] - pub notes: String, - pub pub_date: String, - pub platforms: HashMap, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct RuntimeLatestReleasePlatform { - pub url: String, - pub size: u64, - pub sha256: String, -} - -#[derive(Debug, Clone)] -pub struct PreparedRuntimeUpdate { - pub version: String, - pub artifact_path: PathBuf, - pub tasks_file: PathBuf, -} diff --git a/src-tauri/src/services/updater/update_service.rs b/src-tauri/src/services/updater/update_service.rs index 2f19ffac..d63b0e57 100644 --- a/src-tauri/src/services/updater/update_service.rs +++ b/src-tauri/src/services/updater/update_service.rs @@ -8,7 +8,6 @@ use crate::infrastructure::updater::types::{ FoundUpdatesPayload, InstallStrategy, LatestRelease, NoUpdatesPayload, UpdateEvent, }; use crate::infrastructure::updater::{checker, downloader, planner, service, verifier}; -use crate::services::runtime_updater::RuntimeUpdateService; use crate::services::updater::types::PreparedUpdateInfo; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -54,10 +53,6 @@ fn map_update_check_error(stage: &str, err: impl std::fmt::Display) -> Error { impl UpdateService { pub async fn get_prepared_update() -> Result> { - if let Some(update) = RuntimeUpdateService::peek_prepared_update().await? { - return Ok(Some(update)); - } - Ok(None) } @@ -73,10 +68,12 @@ impl UpdateService { .ok_or("当前没有可安装的更新")?, }; - match resolved_kind.as_str() { - "runtime" => RuntimeUpdateService::start_prepared_install(app_handle).await, - _ => Err(format!("不支持的更新类型: {}", resolved_kind).into()), - } + let _ = app_handle; + Err(format!( + "不支持独立更新组件: {}。环境运行时现已内嵌到 Simprint,请更新主程序。", + resolved_kind + ) + .into()) } /// 简单检查是否有可用更新(仅检查,不缓存计划,不发送事件) diff --git a/src-tauri/tauri.conf.fixed.json b/src-tauri/tauri.conf.fixed.json index b6bdba85..8bd20713 100644 --- a/src-tauri/tauri.conf.fixed.json +++ b/src-tauri/tauri.conf.fixed.json @@ -19,9 +19,6 @@ "bundle": { "active": true, "targets": "nsis", - "resources": { - "resources/simprint-runtime.exe": "simprint-runtime.exe" - }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5871e63b..78bd8c5f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,9 +19,6 @@ "bundle": { "active": true, "targets": "nsis", - "resources": { - "resources/simprint-runtime.exe": "simprint-runtime.exe" - }, "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tauri.conf.window.download.json b/src-tauri/tauri.conf.window.download.json index 8f2e4aac..f3f98b0c 100644 --- a/src-tauri/tauri.conf.window.download.json +++ b/src-tauri/tauri.conf.window.download.json @@ -19,9 +19,6 @@ "bundle": { "active": true, "targets": "nsis", - "resources": { - "resources/simprint-runtime.exe": "simprint-runtime.exe" - }, "icon": [ "icons/Square284x284Logo.png", "icons/icon.icns",