diff --git a/Cargo.toml b/Cargo.toml index 4cef76448..26978bf52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,7 +96,7 @@ toml = "0.8" git2 = { version = "0.18", default-features = false, features = ["https", "vendored-libgit2"] } # Terminal -portable-pty = "0.8" +portable-pty = "0.9.0" vte = "0.15.0" # Grep (search) @@ -113,15 +113,17 @@ similar = "2.5" urlencoding = "2.1" # Tauri (desktop only) - tauri = { version = "2", features = ["unstable", "macos-private-api", "tray-icon"] } +tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } +tauri-plugin-dialog = "2" tauri-plugin-opener = "2" -tauri-plugin-dialog = "2.6" tauri-plugin-fs = "2" tauri-plugin-log = "2" tauri-plugin-autostart = "2" tauri-plugin-notification = "2" tauri-plugin-updater = "2" -tauri-build = { version = "2", features = [] } +tauri-build = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony", features = [] } +napi-ohos = { version = "1.1" } +napi-derive-ohos = { version = "1.1" } # Windows-specific dependencies win32job = "2.0" @@ -163,3 +165,13 @@ lto = false codegen-units = 16 strip = false incremental = true + +[patch.crates-io] +wry = { git = "https://github.com/richerfu/wry"} +tao = { git = "https://github.com/richerfu/tao", branch = "feat-ohos-webview"} +openharmony-ability = {git = "https://github.com/harmony-contrib/openharmony-ability.git"} +openharmony-ability-derive = {git = "https://github.com/harmony-contrib/openharmony-ability.git"} +tauri = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony"} +tauri-runtime = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony"} +tauri-macros = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony"} +tauri-runtime-wry = { git = "https://github.com/richerfu/tauri", branch = "feat/open-harmony"} \ No newline at end of file diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index afbc88c15..c101ace9d 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -30,9 +30,8 @@ tauri-plugin-opener = { workspace = true } tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } tauri-plugin-log = { workspace = true } -tauri-plugin-autostart = { workspace = true } -tauri-plugin-notification = { workspace = true } -tauri-plugin-updater = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } # Inherited from workspace tokio = { workspace = true } @@ -44,7 +43,6 @@ chrono = { workspace = true } uuid = { workspace = true } regex = { workspace = true } dirs = { workspace = true } -dark-light = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } @@ -54,8 +52,6 @@ thiserror = "1.0" futures = { workspace = true } async-trait = { workspace = true } sha1 = { workspace = true } -screenshots = "0.8" -enigo = "0.2" image = { version = "0.24", default-features = false, features = ["png", "jpeg"] } resvg = { version = "0.47.0", default-features = false } @@ -90,6 +86,6 @@ default = [] # Never enabled in release profile intended for end users. devtools = ["tauri/devtools"] -[target.'cfg(target_os = "linux")'.dependencies] -atspi = "0.29" +[target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] leptess = "0.14.0" +atspi = "0.29" diff --git a/src/apps/desktop/capabilities/browser-webview.json b/src/apps/desktop/capabilities/browser-webview.json deleted file mode 100644 index 5fcbaf826..000000000 --- a/src/apps/desktop/capabilities/browser-webview.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "browser-webview", - "description": "Minimal permissions for embedded browser webviews to emit events back to the host", - "webviews": ["embedded-browser-*"], - "local": true, - "remote": { - "urls": ["https://*", "https://*:*", "http://*", "http://*:*"] - }, - "permissions": [ - "core:event:allow-emit" - ] -} diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 239dcee07..ab26713ec 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -1,110 +1,89 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "BitFun default capabilities", - "windows": ["main", "agent-companion-pet"], - "permissions": [ - "log:default", - "autostart:default", - "core:default", - "core:path:default", - "core:event:default", - "core:event:allow-listen", - "core:event:allow-emit", - "core:window:default", - "core:webview:default", - "core:webview:allow-create-webview", - "core:webview:allow-set-webview-position", - "core:webview:allow-set-webview-size", - "core:webview:allow-set-webview-focus", - "core:webview:allow-reparent", - "core:webview:allow-webview-show", - "core:webview:allow-webview-hide", - "core:webview:allow-webview-close", - "core:window:allow-create", - "core:window:allow-set-focus", - "core:window:allow-set-always-on-top", - "core:window:allow-set-position", - "core:window:allow-set-size", - "core:window:allow-set-decorations", - "core:window:allow-set-title-bar-style", - "core:window:allow-set-skip-taskbar", - "core:window:allow-set-resizable", - "core:window:allow-current-monitor", - "core:window:allow-outer-position", - "core:window:allow-outer-size", - "core:window:allow-is-fullscreen", - "core:window:allow-is-maximized", - "core:window:allow-center", - "core:window:allow-close", - "core:window:allow-hide", - "core:window:allow-maximize", - "core:window:allow-minimize", - "core:window:allow-show", - "core:window:allow-start-dragging", - "core:window:allow-unmaximize", - "core:window:allow-unminimize", - "core:window:allow-set-min-size", - "dialog:default", - "dialog:allow-open", - "dialog:allow-save", - "dialog:allow-ask", - "dialog:allow-confirm", - "dialog:allow-message", - "opener:default", - "opener:allow-open-url", - { - "identifier": "opener:allow-open-path", - "allow": [ - { "path": "$APPDATA/**" }, - { "path": "$HOME/**" } - ] - }, - "opener:allow-reveal-item-in-dir", - "fs:default", - "fs:allow-read-file", - "fs:allow-write-file", - "fs:allow-read-dir", - "fs:allow-copy-file", - "fs:allow-create", - "fs:allow-mkdir", - "fs:allow-remove", - "fs:allow-rename", - "fs:allow-exists", - "fs:allow-read-text-file", - "fs:allow-write-text-file", - "fs:allow-stat", - "fs:allow-lstat", - "fs:allow-fstat", - "fs:allow-truncate", - "fs:allow-ftruncate", - "fs:allow-open", - "fs:allow-read", - "fs:allow-write", - "fs:allow-seek", - "fs:allow-unwatch", - "fs:allow-watch", - "fs:read-all", - "fs:write-all", - "fs:scope-app-recursive", - { - "identifier": "fs:allow-home-read-recursive", - "allow": [ - { "path": "$HOME/**" } - ] - }, - { - "identifier": "fs:allow-home-write-recursive", - "allow": [ - { "path": "$HOME/**" } - ] - }, - "notification:default", - "notification:allow-notify", - "notification:allow-show", - "notification:allow-request-permission", - "notification:allow-check-permissions", - "notification:allow-permission-state", - "notification:allow-is-permission-granted" - ] -} + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "BitFun default capabilities", + "windows": ["main"], + "permissions": [ + "log:default", + "core:default", + "core:path:default", + "core:event:default", + "core:event:allow-listen", + "core:event:allow-emit", + "core:window:default", + "core:webview:default", + "core:webview:allow-create-webview", + "core:webview:allow-set-webview-position", + "core:webview:allow-set-webview-size", + "core:webview:allow-set-webview-focus", + "core:webview:allow-reparent", + "core:webview:allow-webview-show", + "core:webview:allow-webview-hide", + "core:webview:allow-webview-close", + "core:window:allow-create", + "core:window:allow-set-focus", + "core:window:allow-set-always-on-top", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:window:allow-set-decorations", + "core:window:allow-set-title-bar-style", + "core:window:allow-set-skip-taskbar", + "core:window:allow-set-resizable", + "core:window:allow-current-monitor", + "core:window:allow-outer-position", + "core:window:allow-outer-size", + "core:window:allow-is-maximized", + "core:window:allow-center", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-maximize", + "core:window:allow-minimize", + "core:window:allow-show", + "core:window:allow-start-dragging", + "core:window:allow-unmaximize", + "core:window:allow-unminimize", + "core:window:allow-set-min-size", + "opener:default", + "opener:allow-open-url", + "opener:allow-open-path", + "opener:allow-reveal-item-in-dir", + "fs:default", + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-read-dir", + "fs:allow-copy-file", + "fs:allow-create", + "fs:allow-mkdir", + "fs:allow-remove", + "fs:allow-rename", + "fs:allow-exists", + "fs:allow-read-text-file", + "fs:allow-write-text-file", + "fs:allow-stat", + "fs:allow-lstat", + "fs:allow-fstat", + "fs:allow-truncate", + "fs:allow-ftruncate", + "fs:allow-open", + "fs:allow-read", + "fs:allow-write", + "fs:allow-seek", + "fs:allow-unwatch", + "fs:allow-watch", + "fs:read-all", + "fs:write-all", + "fs:scope-app-recursive", + { + "identifier": "fs:allow-home-read-recursive", + "allow": [ + { "path": "$HOME/**" } + ] + }, + { + "identifier": "fs:allow-home-write-recursive", + "allow": [ + { "path": "$HOME/**" } + ] + } + ] +} \ No newline at end of file diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index fffa0e9a6..31c05429e 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -1,3 +1,6 @@ + + + //! Application state management use crate::api::workspace_activation::spawn_workspace_background_warmup; @@ -181,7 +184,7 @@ impl AppState { "worker_host.js not found in any candidate location; \ MiniApp Workers will not start" ); - std::path::PathBuf::from("worker_host.js") + std::path::PathBuf::from("/data/storage/el2/base/files").join("woker_host.js") } }; let js_worker_pool = JsWorkerPool::new(path_manager, worker_host_path) diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index b0189e156..b204f7f78 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -20,13 +20,7 @@ pub async fn browser_webview_eval( app: tauri::AppHandle, request: WebviewEvalRequest, ) -> Result<(), String> { - let webview = app - .get_webview(&request.label) - .ok_or_else(|| format!("Webview not found: {}", request.label))?; - - webview - .eval(&request.script) - .map_err(|e| format!("eval failed: {e}")) + Err("Webview not found".to_string()) } #[derive(Debug, Deserialize)] @@ -45,15 +39,5 @@ pub async fn browser_get_url( app: tauri::AppHandle, request: WebviewLabelRequest, ) -> Result { - let webview = app - .get_webview(&request.label) - .ok_or_else(|| format!("Webview not found: {}", request.label))?; - - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| webview.url())); - - match result { - Ok(Ok(url)) => Ok(url.to_string()), - Ok(Err(e)) => Err(format!("url failed: {e}")), - Err(_) => Err("url unavailable (webview URL is nil)".to_string()), - } + Err("Url is unavailable (webview URL is nil)".to_string()) } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 585668893..45f4cff2f 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2732,6 +2732,12 @@ pub async fn reveal_in_explorer( )) } }; + #[cfg(target_env = "ohos")] + { + use crate::ohos::ohos_file_system::reveal_in_oh_explorer; + let _ = reveal_in_oh_explorer(path.to_string_lossy().to_string()); + return Ok(()); + } if !path.exists() { return Err(format!("Path does not exist: {}", request.path)); } diff --git a/src/apps/desktop/src/api/computer_use_api.rs b/src/apps/desktop/src/api/computer_use_api.rs index 654fcde05..b1377145f 100644 --- a/src/apps/desktop/src/api/computer_use_api.rs +++ b/src/apps/desktop/src/api/computer_use_api.rs @@ -1,9 +1,6 @@ //! Tauri commands for Computer use (permissions + settings deep links). use crate::api::app_state::AppState; -use crate::computer_use::DesktopComputerUseHost; -use bitfun_core::agentic::tools::computer_use_host::ComputerUseHost; -use bitfun_core::service::config::types::AIConfig; use serde::{Deserialize, Serialize}; use tauri::State; @@ -27,31 +24,19 @@ pub struct ComputerUseOpenSettingsRequest { pub async fn computer_use_get_status( state: State<'_, AppState>, ) -> Result { - let ai: AIConfig = state - .config_service - .get_config(Some("ai")) - .await - .map_err(|e| e.to_string())?; - - let host = DesktopComputerUseHost::new(); - let snap = host - .permission_snapshot() - .await - .map_err(|e| e.to_string())?; - - Ok(ComputerUseStatusResponse { - computer_use_enabled: ai.computer_use_enabled, - accessibility_granted: snap.accessibility_granted, - screen_capture_granted: snap.screen_capture_granted, - platform_note: snap.platform_note, - }) + Err("computer_use_get_status error".to_string()) } #[tauri::command] pub async fn computer_use_request_permissions() -> Result<(), String> { - let host = DesktopComputerUseHost::new(); - host.prompt_for_missing_permissions(); - Ok(()) + #[cfg(not(target_env = "ohos"))] + { + let host = DesktopComputerUseHost::new(); + host.prompt_for_missing_permissions(); + Ok(()) + } + #[cfg(target_env = "ohos")] + Err("computer_use_request_permissions error".to_string()) } #[tauri::command] diff --git a/src/apps/desktop/src/api/i18n_api.rs b/src/apps/desktop/src/api/i18n_api.rs index 5284308b8..551eb58fd 100644 --- a/src/apps/desktop/src/api/i18n_api.rs +++ b/src/apps/desktop/src/api/i18n_api.rs @@ -95,6 +95,7 @@ pub async fn i18n_set_language( } // Rebuild the system tray menu in the new language. + #[cfg(not(target_env = "ohos"))] { let app_handle = _app.clone(); tauri::async_runtime::spawn(async move { diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 707305041..9311a83a9 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -41,6 +41,7 @@ pub mod subagent_api; pub mod system_api; pub mod terminal_api; pub mod tool_api; +pub mod ohos; pub mod workspace_activation; pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace}; diff --git a/src/apps/desktop/src/api/ohos/browser.rs b/src/apps/desktop/src/api/ohos/browser.rs new file mode 100644 index 000000000..879b4b4d9 --- /dev/null +++ b/src/apps/desktop/src/api/ohos/browser.rs @@ -0,0 +1,26 @@ +use bitfun_core::util::JS_THREADSAFE_FUNCTION; +use log::{info,error}; +#[tauri::command] +pub async fn open_browser(url: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("open_browser").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(url)).await; + match res { + Ok(res) => match res.await{ + Ok(_) => { + info!("open_browser successfully"); + Ok(()) + }, + Err(err) => { + error!("open_browser failed: {}", err); + Err(err.to_string()) + } + }, + Err(err) => Err(err.to_string()), + } +} \ No newline at end of file diff --git a/src/apps/desktop/src/api/ohos/mod.rs b/src/apps/desktop/src/api/ohos/mod.rs new file mode 100644 index 000000000..6eedc89d3 --- /dev/null +++ b/src/apps/desktop/src/api/ohos/mod.rs @@ -0,0 +1,3 @@ +pub mod ohos_file_system; +pub mod window; +pub mod browser; \ No newline at end of file diff --git a/src/apps/desktop/src/api/ohos/ohos_file_system.rs b/src/apps/desktop/src/api/ohos/ohos_file_system.rs new file mode 100644 index 000000000..20e147f3e --- /dev/null +++ b/src/apps/desktop/src/api/ohos/ohos_file_system.rs @@ -0,0 +1,32 @@ +use bitfun_core::util::{JS_THREADSAFE_FUNCTION, open_dialog_file}; +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; +#[tauri::command] +pub async fn open_oh_file_dialog() -> Result { + open_dialog_file().await +} + +#[tauri::command] +pub async fn set_theme_mode(theme: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_theme_mode").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok(theme),ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} + +#[tauri::command] +pub fn reveal_in_oh_explorer(path: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("reveal_in_explorer").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok(path),ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} \ No newline at end of file diff --git a/src/apps/desktop/src/api/ohos/window.rs b/src/apps/desktop/src/api/ohos/window.rs new file mode 100644 index 000000000..8cc7e2c7a --- /dev/null +++ b/src/apps/desktop/src/api/ohos/window.rs @@ -0,0 +1,112 @@ +use bitfun_core::util::JS_THREADSAFE_FUNCTION; +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; + +#[tauri::command] +pub fn handle_min_window() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("handle_min_window").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok("".to_string()),ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} +#[tauri::command] +pub fn handle_max_window() -> Result<(),String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + + lock.get("handle_max_window").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok("".to_string()), ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} +#[tauri::command] +pub fn handle_restore_window() -> Result<(),String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("handle_restore_window").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok("".to_string()), ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} +#[tauri::command] +pub async fn window_is_minimized() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("window_is_minimized").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("str".to_string())).await; + match res { + Ok(err) => match err.await{ + Ok(result) => { + if result.eq("true") { + Ok(true) + } else { + Ok(false) + } + }, + Err(err) => Err(err.to_string()), + } + Err(err) => Err(err.to_string()), + } +} +#[tauri::command] +pub fn window_start_dragging() -> Result<(),String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("window_start_dragging").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok("".to_string()), ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} +#[tauri::command] +pub fn close_window() -> Result<(),String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("close_window").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + function.call(Ok("".to_string()), ThreadsafeFunctionCallMode::NonBlocking); + Ok(()) +} +#[tauri::command] +pub async fn window_is_maximized() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("window_is_maximized").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("str".to_string())).await; + match res { + Ok(err) => match err.await { + Ok(result) => { + if result.eq("true") { + Ok(true) + } else { + Ok(false) + } + }, + Err(err) => Err(err.to_string()), + } + Err(err) => Err(err.to_string()), + } +} \ No newline at end of file diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 5af571179..967d9a9c3 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -32,7 +32,7 @@ pub fn set_mobile_web_resource_path(path: PathBuf) { /// and restore any previously paired bot connections. Without this, bots /// only start listening after the user first opens the Remote Connect dialog. pub fn init_on_startup() { - tokio::spawn(async { + tauri::async_runtime::spawn(async { if let Err(e) = ensure_service().await { log::warn!("Remote connect startup init failed: {e}"); } @@ -423,6 +423,12 @@ pub async fn remote_connect_stop() -> Result<(), String> { Ok(()) } +#[tauri::command] +pub async fn send_remote_connect_dialog_status(is_open: bool) -> Result<(), String> { + bitfun_core::service::remote_connect::send_remote_dialog_status(is_open); + Ok(()) +} + #[tauri::command] pub async fn remote_connect_stop_bot() -> Result<(), String> { let holder = get_service_holder(); diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index e602ccfbc..43eaca796 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -6,6 +6,7 @@ use crate::api::app_state::AppState; use bitfun_core::service::system; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, Manager, Position, Size, State}; +#[cfg(not(target_env = "ohos"))] use tauri_plugin_updater::UpdaterExt; /// Emitted during `install_update` download; matches `installUpdateWithProgress` / frontend listener. @@ -71,25 +72,37 @@ pub async fn check_for_updates( app: AppHandle, request: CheckForUpdatesRequest, ) -> Result { - let _ = request; - let updater = app.updater().map_err(|e| e.to_string())?; - let update = updater.check().await.map_err(|e| e.to_string())?; - match update { - Some(u) => Ok(CheckForUpdatesResponse { - update_available: true, - current_version: u.current_version.clone(), - latest_version: Some(u.version.clone()), - release_notes: u.body.clone(), - release_date: u.date.map(|d| d.to_string()), - }), - None => Ok(CheckForUpdatesResponse { - update_available: false, - current_version: app.package_info().version.to_string(), - latest_version: None, - release_notes: None, - release_date: None, - }), + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let updater = app.updater().map_err(|e| e.to_string())?; + let update = updater.check().await.map_err(|e| e.to_string())?; + match update { + Some(u) => Ok(CheckForUpdatesResponse { + update_available: true, + current_version: u.current_version.clone(), + latest_version: Some(u.version.clone()), + release_notes: u.body.clone(), + release_date: u.date.map(|d| d.to_string()), + }), + None => Ok(CheckForUpdatesResponse { + update_available: false, + current_version: app.package_info().version.to_string(), + latest_version: None, + release_notes: None, + release_date: None, + }), + } } + #[cfg(target_env = "ohos")] + Ok(CheckForUpdatesResponse { + update_available: false, + current_version: Default::default(), + latest_version: None, + release_notes: None, + release_date: None, + }) + } #[derive(Debug, Deserialize, Default)] @@ -99,51 +112,60 @@ pub struct InstallUpdateRequest {} /// Downloads and installs the latest update from the updater endpoint (re-checks remote). #[tauri::command] pub async fn install_update(app: AppHandle, request: InstallUpdateRequest) -> Result<(), String> { - let _ = request; - let updater = app.updater().map_err(|e| e.to_string())?; - let update = updater.check().await.map_err(|e| e.to_string())?; - let Some(update) = update else { - return Err("No update available".to_string()); - }; - let app_handle = app.clone(); - let progress = Arc::new(Mutex::new((0u64, None::))); - let progress_chunk = Arc::clone(&progress); - let app_chunk = app_handle.clone(); - update - .download_and_install( - move |chunk_len, content_len| { - let (downloaded, total) = { - let mut g = progress_chunk - .lock() - .expect("update progress mutex poisoned"); - g.0 = g.0.saturating_add(chunk_len as u64); - g.1 = g.1.or(content_len); - (g.0, g.1) - }; - let _ = app_chunk.emit( - UPDATE_PROGRESS_EVENT, - UpdateProgressPayload { downloaded, total }, - ); - }, - { - let app_done = app_handle.clone(); - let progress_done = Arc::clone(&progress); - move || { + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let updater = app.updater().map_err(|e| e.to_string())?; + let update = updater.check().await.map_err(|e| e.to_string())?; + let Some(update) = update else { + return Err("No update available".to_string()); + }; + let app_handle = app.clone(); + let progress = Arc::new(Mutex::new((0u64, None::))); + let progress_chunk = Arc::clone(&progress); + let app_chunk = app_handle.clone(); + update + .download_and_install( + move |chunk_len, content_len| { let (downloaded, total) = { - let g = progress_done + let mut g = progress_chunk .lock() .expect("update progress mutex poisoned"); + g.0 = g.0.saturating_add(chunk_len as u64); + g.1 = g.1.or(content_len); (g.0, g.1) }; - let _ = app_done.emit( + let _ = app_chunk.emit( UPDATE_PROGRESS_EVENT, UpdateProgressPayload { downloaded, total }, ); - } - }, - ) - .await - .map_err(|e| e.to_string()) + }, + { + let app_done = app_handle.clone(); + let progress_done = Arc::clone(&progress); + move || { + let (downloaded, total) = { + let g = progress_done + .lock() + .expect("update progress mutex poisoned"); + (g.0, g.1) + }; + let _ = app_done.emit( + UPDATE_PROGRESS_EVENT, + UpdateProgressPayload { downloaded, total }, + ); + } + }, + ) + .await + .map_err(|e| e.to_string()) + } + + #[cfg(target_env = "ohos")] + { + Err("Not supported on this platform".to_string()) + } + } #[derive(Debug, Deserialize, Default)] @@ -255,7 +277,18 @@ pub async fn run_system_command( success: result.success, }) } - +#[tauri::command] +pub async fn open_external_ohos(url: String) -> Result<(), String> { + #[cfg(target_env = "ohos")] + { + use crate::api::ohos::browser::open_browser; + open_browser(url).await + } + #[cfg(not(target_env = "ohos"))] + { + Err("open_external is only supported on ohos".to_string()) + } +} #[tauri::command] pub async fn set_macos_edit_menu_mode( state: State<'_, AppState>, @@ -386,11 +419,18 @@ pub async fn quit_app(app: tauri::AppHandle) -> Result<(), String> { /// dialog when the user chooses to minimize instead of quitting). #[tauri::command] pub async fn minimize_to_tray(app: tauri::AppHandle) -> Result<(), String> { - if let Some(window) = app.get_webview_window("main") { - window.hide().map_err(|e| e.to_string())?; - log::info!("Main window minimized to tray via command"); + #[cfg(not(target_env = "ohos"))] + { + if let Some(window) = app.get_webview_window("main") { + window.hide().map_err(|e| e.to_string())?; + log::info!("Main window minimized to tray via command"); + } + Ok(()) + } + #[cfg(target_env = "ohos")] + { + Err("Do not support the minimize to tray via command".to_string()) } - Ok(()) } /// Toggle OS-level fullscreen for the Desktop main window. @@ -423,94 +463,109 @@ pub async fn toggle_main_window_fullscreen( app: tauri::AppHandle, request: ToggleMainWindowFullscreenRequest, ) -> Result { - let _ = request; - let Some(window) = app.get_webview_window("main") else { - return Err("Main window not found".to_string()); - }; - - let current_fullscreen = window.is_fullscreen().map_err(|error| { - format!("Failed to read main window fullscreen state: {}", error) - })?; - let current_maximized = window.is_maximized().map_err(|error| { - format!("Failed to read main window maximize state: {}", error) - })?; - let restore_maximized_after_fullscreen = *main_window_fullscreen_restore_maximized() - .lock() - .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; - - let transition = plan_main_window_fullscreen_transition( - current_fullscreen, - current_maximized, - restore_maximized_after_fullscreen, - should_apply_maximized_fullscreen_monitor_bounds(), - ); - - if transition.next_fullscreen { - if let Err(error) = window.set_fullscreen(true) { - return Err(format!("Failed to enter main window fullscreen: {}", error)); + #[cfg(target_env = "ohos")] + { + Err("Do not support the toggle main_window_fullscreen".to_string()) + } + #[cfg(not(target_env = "ohos"))] + { + let _ = request; + let Some(window) = app.get_webview_window("main") else { + return Err("Main window not found".to_string()); + }; + + let current_fullscreen = window.is_fullscreen().map_err(|error| { + format!("Failed to read main window fullscreen state: {}", error) + })?; + let current_maximized = window.is_maximized().map_err(|error| { + format!("Failed to read main window maximize state: {}", error) + })?; + let restore_maximized_after_fullscreen = *crate::api::system_api::main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())?; + + let transition = crate::api::system_api::plan_main_window_fullscreen_transition( + current_fullscreen, + current_maximized, + restore_maximized_after_fullscreen, + crate::api::system_api::should_apply_maximized_fullscreen_monitor_bounds(), + ); + + if transition.next_fullscreen { + if let Err(error) = window.set_fullscreen(true) { + return Err(format!("Failed to enter main window fullscreen: {}", error)); + } + + if transition.should_apply_monitor_bounds_after_enter { + crate::api::system_api::apply_main_window_fullscreen_monitor_bounds(&app, &window)?; + } + + *crate::api::system_api::main_window_fullscreen_restore_maximized() + .lock() + .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = + transition.next_restore_maximized_after_fullscreen; + + return Ok(crate::api::system_api::read_main_window_fullscreen_response( + &window, + true, + false, + )); } - if transition.should_apply_monitor_bounds_after_enter { - apply_main_window_fullscreen_monitor_bounds(&app, &window)?; + window + .set_fullscreen(false) + .map_err(|error| format!("Failed to exit main window fullscreen: {}", error))?; + + let mut restored_maximized = false; + if transition.should_restore_maximized_after_exit { + let is_already_maximized = window.is_maximized().unwrap_or(false); + if !is_already_maximized { + window.maximize().map_err(|error| { + format!("Failed to restore maximize after fullscreen: {}", error) + })?; + } + restored_maximized = true; } - *main_window_fullscreen_restore_maximized() + *crate::api::system_api::main_window_fullscreen_restore_maximized() .lock() .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = transition.next_restore_maximized_after_fullscreen; - return Ok(read_main_window_fullscreen_response( + Ok(crate::api::system_api::read_main_window_fullscreen_response( &window, - true, false, - )); + restored_maximized, + )) } - - window - .set_fullscreen(false) - .map_err(|error| format!("Failed to exit main window fullscreen: {}", error))?; - - let mut restored_maximized = false; - if transition.should_restore_maximized_after_exit { - let is_already_maximized = window.is_maximized().unwrap_or(false); - if !is_already_maximized { - window.maximize().map_err(|error| { - format!("Failed to restore maximize after fullscreen: {}", error) - })?; - } - restored_maximized = true; - } - - *main_window_fullscreen_restore_maximized() - .lock() - .map_err(|_| "Main window fullscreen restore state is unavailable".to_string())? = - transition.next_restore_maximized_after_fullscreen; - - Ok(read_main_window_fullscreen_response( - &window, - false, - restored_maximized, - )) + } fn apply_main_window_fullscreen_monitor_bounds( app: &tauri::AppHandle, window: &tauri::WebviewWindow, ) -> Result<(), String> { - let monitor = window - .current_monitor() - .map_err(|error| format!("Failed to read current monitor for fullscreen: {}", error))? - .or_else(|| app.primary_monitor().ok().flatten()) - .ok_or_else(|| "Failed to resolve monitor for fullscreen".to_string())?; - - window - .set_position(Position::Physical(*monitor.position())) - .map_err(|error| format!("Failed to align fullscreen window position: {}", error))?; - window - .set_size(Size::Physical(*monitor.size())) - .map_err(|error| format!("Failed to align fullscreen window size: {}", error))?; - - Ok(()) + #[cfg(target_env = "ohos")] + { + Err("Do not support the apply the main windows fullscreen".to_string()) + } + #[cfg(not(target_env = "ohos"))] + { + let monitor = window + .current_monitor() + .map_err(|error| format!("Failed to read current monitor for fullscreen: {}", error))? + .or_else(|| app.primary_monitor().ok().flatten()) + .ok_or_else(|| "Failed to resolve monitor for fullscreen".to_string())?; + + window + .set_position(Position::Physical(*monitor.position())) + .map_err(|error| format!("Failed to align fullscreen window position: {}", error))?; + window + .set_size(Size::Physical(*monitor.size())) + .map_err(|error| format!("Failed to align fullscreen window size: {}", error))?; + + Ok(()) + } } #[cfg(target_os = "windows")] @@ -529,13 +584,7 @@ pub async fn send_system_notification( app: tauri::AppHandle, request: SendNotificationRequest, ) -> Result<(), String> { - use tauri_plugin_notification::NotificationExt; - - let mut builder = app.notification().builder().title(&request.title); - if let Some(body) = &request.body { - builder = builder.body(body); - } - builder.show().map_err(|e| e.to_string()) + Err("No notification provided".to_string()) } #[cfg(test)] diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index abd0b8e63..24962acde 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -1,5 +1,6 @@ //! Terminal API +use bitfun_core::infrastructure::PathManager; use log::{error, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -72,8 +73,8 @@ impl TerminalState { /// Get the scripts directory path for shell integration /// Uses the same path structure as PathManager fn get_scripts_dir() -> PathBuf { - dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) + PathManager::new().map(|p| p.home_dir()) + .unwrap_or_else(|_| PathBuf::from(".")) .join("bitfun") .join("temp") .join("scripts") @@ -828,7 +829,7 @@ pub async fn terminal_get_history( } pub fn start_terminal_event_loop(terminal_state: TerminalState, app_handle: AppHandle) { - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let api = match terminal_state.get_or_init_api().await { Ok(api) => api, Err(e) => { diff --git a/src/apps/desktop/src/computer_use/mod.rs b/src/apps/desktop/src/computer_use/mod.rs index be0e294a4..403e6e5a9 100644 --- a/src/apps/desktop/src/computer_use/mod.rs +++ b/src/apps/desktop/src/computer_use/mod.rs @@ -2,7 +2,7 @@ mod desktop_host; mod interactive_filter; -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] mod linux_ax_ui; #[cfg(target_os = "macos")] mod macos_ax_dump; @@ -16,6 +16,9 @@ mod macos_bg_input; mod macos_list_apps; mod screen_ocr; mod som_overlay; +#[cfg(not(target_env = "ohos"))] +mod screen_ocr; +#[cfg(not(target_env = "ohos"))] mod ui_locate_common; #[cfg(target_os = "windows")] mod windows_ax_ui; diff --git a/src/apps/desktop/src/computer_use/screen_ocr.rs b/src/apps/desktop/src/computer_use/screen_ocr.rs index 7fa436c75..456848980 100644 --- a/src/apps/desktop/src/computer_use/screen_ocr.rs +++ b/src/apps/desktop/src/computer_use/screen_ocr.rs @@ -40,11 +40,6 @@ pub fn find_text_matches( return windows_backend::find_text_matches(shot, &query); } - #[cfg(target_os = "linux")] - { - return linux_backend::find_text_matches(shot, &query); - } - #[allow(unreachable_code)] Err(BitFunError::tool( "move_to_text OCR is not supported on this platform.".to_string(), @@ -817,7 +812,7 @@ mod windows_backend { // --------------------------------------------------------------------------- // Linux: Tesseract OCR via leptess bindings // --------------------------------------------------------------------------- -#[cfg(target_os = "linux")] +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] mod linux_backend { use super::{ filter_and_rank, fuzzy_text_matches_query, image_box_to_global_match, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 7c0d84f0d..809c9e6e3 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -2,14 +2,13 @@ //! BitFun Desktop - Tauri-based desktop application with TransportAdapter architecture pub mod api; -pub mod computer_use; pub mod logging; pub mod macos_menubar; pub mod theme; +#[cfg(not(target_env = "ohos"))] pub mod tray; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; -use bitfun_core::agentic::tools::computer_use_host::ComputerUseHostRef; use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::infrastructure::{get_path_manager_arc, try_get_path_manager_arc}; use bitfun_core::service::search::get_global_workspace_search_service; @@ -28,6 +27,12 @@ use tauri::Manager; // Re-export API pub use api::*; +use crate::ohos::ohos_file_system::{open_oh_file_dialog, set_theme_mode}; +use crate::ohos::window::{ + close_window,handle_max_window,handle_min_window,handle_restore_window,window_is_maximized, + window_is_minimized, window_start_dragging +}; +use std::path::PathBuf; use api::acp_client_api::*; use api::clipboard_file_api::*; use api::commands::*; @@ -64,10 +69,11 @@ pub struct SchedulerState { pub scheduler: Arc, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct WebdriverBridgeResultRequest { - payload: serde_json::Value, +pub struct OhosPlatform { + pub version: String, + pub devic_type: String, + pub api_level: i32, + pub feature: Vec, } #[cfg(target_os = "macos")] @@ -184,15 +190,36 @@ async fn hide_main_window_after_close_request(app: tauri::AppHandle) -> Result<( Ok(()) } -#[tauri::command] -async fn webdriver_bridge_result(request: WebdriverBridgeResultRequest) -> Result<(), String> { - log::debug!("webdriver_bridge_result command invoked"); - bitfun_webdriver::handle_bridge_result(request.payload) + +impl Default for OhosPlatform { + fn default() -> Self { + Self { + version: "6.0.0".to_string(), + devic_type: "2in1".to_string(), + api_level: 12, + feature: vec![ + "web_view".to_string(), + "file_system".to_string(), + "network".to_string(), + "storage".to_string(), + ], + } + } } /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub async fn run() { +pub fn run() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(16) + .enable_all() + .build() + .expect("multi thread runtime failed"); + runtime.block_on(_run()); +} + +/// Tauri entry point. +pub async fn _run() { let startup_started = Instant::now(); let mut startup_timings = TimingCollector::default(); let in_debug = cfg!(debug_assertions) || std::env::var("DEBUG").unwrap_or_default() == "1"; @@ -285,15 +312,7 @@ pub async fn run() { let app = tauri::Builder::default() .plugin(logging::build_log_plugin(log_targets)) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) - .plugin( - tauri_plugin_autostart::Builder::new() - .app_name("BitFun") - .build(), - ) - .plugin(tauri_plugin_notification::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) .manage(app_state) .manage(coordinator_state) .manage(scheduler_state) @@ -377,18 +396,11 @@ pub async fn run() { { let candidates = ["mobile-web/dist", "mobile-web", "dist"]; let mut found = false; - for candidate in &candidates { - if let Ok(p) = app - .path() - .resolve(candidate, tauri::path::BaseDirectory::Resource) - { - if p.join("index.html").exists() { - log::info!("Found bundled mobile-web at: {}", p.display()); - api::remote_connect_api::set_mobile_web_resource_path(p); - found = true; - break; - } - } + let path = PathBuf::from("/data/storage/el1/bundle/entry/resources/resfile/dist"); + if path.join("index.html").exists() { + log::info!("Found bundled mobile-web at: {}", path.display()); + api::remote_connect_api::set_mobile_web_resource_path(path); + found = true; } if !found { // Last resort: scan the resource root for any index.html @@ -470,12 +482,10 @@ pub async fn run() { app.state(); let terminal_state_inner = api::terminal_api::TerminalState::new(); let app_handle_clone = app_handle.clone(); - tokio::spawn(async move { - api::terminal_api::start_terminal_event_loop( - terminal_state_inner, - app_handle_clone, - ); - }); + api::terminal_api::start_terminal_event_loop( + terminal_state_inner, + app_handle_clone, + ); } init_mcp_servers(app_handle.clone()); @@ -486,8 +496,11 @@ pub async fn run() { logging::spawn_log_cleanup_task(); // Set up system tray icon. - if let Err(error) = crate::tray::setup_tray(app) { - log::warn!("Failed to set up system tray: {}", error); + #[cfg(not(target_env = "ohos"))] + { + if let Err(error) = crate::tray::setup_tray(app) { + log::warn!("Failed to set up system tray: {}", error); + } } log::info!("BitFun Desktop started successfully"); @@ -566,7 +579,6 @@ pub async fn run() { api::agentic_api::set_subagent_timeout, api::agentic_api::delete_session, api::agentic_api::restore_session, - webdriver_bridge_result, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, api::agentic_api::reject_tool_execution, @@ -894,6 +906,7 @@ pub async fn run() { check_for_updates, install_update, restart_app, + open_external_ohos, send_system_notification, api::system_api::quit_app, api::system_api::minimize_to_tray, @@ -915,6 +928,7 @@ pub async fn run() { api::remote_connect_api::remote_connect_start, api::remote_connect_api::remote_connect_stop, api::remote_connect_api::remote_connect_stop_bot, + api::remote_connect_api::send_remote_connect_dialog_status, api::remote_connect_api::remote_connect_status, api::remote_connect_api::remote_connect_get_form_state, api::remote_connect_api::remote_connect_set_form_state, @@ -1012,6 +1026,17 @@ pub async fn run() { api::announcement_api::never_show_announcement, api::announcement_api::trigger_announcement, api::announcement_api::get_announcement_tips, + // ohos adater + open_oh_file_dialog, + handle_min_window, + handle_max_window, + handle_restore_window, + window_is_maximized, + window_is_minimized, + window_start_dragging, + close_window, + set_theme_mode, + // Debug API (no-op stubs in release builds) api::debug_api::debug_element_picked, api::debug_api::debug_open_devtools, @@ -1076,14 +1101,12 @@ async fn init_agentic_system() -> anyhow::Result<( let tool_registry = tools::registry::get_global_tool_registry(); let tool_state_manager = Arc::new(tools::pipeline::ToolStateManager::new(event_queue.clone())); - let computer_use_host: ComputerUseHostRef = - Arc::new(computer_use::DesktopComputerUseHost::new()); - set_computer_use_desktop_available(true); + set_computer_use_desktop_available(false); let tool_pipeline = Arc::new(tools::pipeline::ToolPipeline::new( tool_registry, tool_state_manager, - Some(computer_use_host), + None, )); let stream_processor = Arc::new(execution::StreamProcessor::new(event_queue.clone())); @@ -1115,7 +1138,7 @@ async fn init_agentic_system() -> anyhow::Result<( event_queue.clone(), session_manager.clone(), context_compressor, - exec_config, + Default::default(), )); let coordinator = Arc::new(coordination::ConversationCoordinator::new( @@ -1185,13 +1208,13 @@ async fn init_function_agents(ai_client_factory: Arc) -> anyhow } fn init_mcp_servers(app_handle: tauri::AppHandle) { - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let _ = app_handle; }); } fn init_acp_clients(app_handle: tauri::AppHandle) { - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let state: tauri::State<'_, api::AppState> = app_handle.state(); if let Some(service) = state.acp_client_service.as_ref() { if let Err(error) = service.initialize_all().await { @@ -1276,7 +1299,7 @@ fn start_event_loop_with_transport( event_router: Arc, transport: Arc, ) { - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { loop { event_queue.wait_for_events().await; loop { @@ -1308,7 +1331,7 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt spawn_runtime_log_level_listener(default_log_level); spawn_workspace_search_feature_listener(app_handle.clone()); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let transport = Arc::new(TauriTransportAdapter::new(app_handle.clone())); let emitter = create_event_emitter(transport); let workspace_identity_watch_service = { @@ -1364,7 +1387,7 @@ async fn resolve_runtime_log_level(default_level: log::LevelFilter) -> log::Leve fn spawn_runtime_log_level_listener(default_level: log::LevelFilter) { use bitfun_core::service::config::{subscribe_config_updates, ConfigUpdateEvent}; - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { if let Some(mut receiver) = subscribe_config_updates() { loop { match receiver.recv().await { @@ -1412,7 +1435,7 @@ fn spawn_workspace_search_feature_listener(app_handle: tauri::AppHandle) { let workspace_search_service = app_state.workspace_search_service.clone(); let workspace_path = app_state.workspace_path.clone(); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let mut feature_enabled = bitfun_core::service::search::workspace_search_feature_enabled().await; @@ -1498,7 +1521,7 @@ fn spawn_ingest_server_with_config_listener() { get_global_config_service, subscribe_config_updates, ConfigUpdateEvent, }; - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { let initial_config = if let Ok(config_service) = get_global_config_service().await { if let Ok(config) = config_service .get_config::(None) diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 695b66624..78d2d513b 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -93,9 +93,27 @@ const fn u8_to_level_filter(value: u8) -> log::LevelFilter { } } -// Default to Debug in early development for easier diagnostics -fn resolve_default_level(_is_debug: bool) -> log::LevelFilter { - log::LevelFilter::Debug +fn resolve_default_level(is_debug: bool) -> log::LevelFilter { + match std::env::var("BITFUN_LOG_LEVEL") { + Ok(val) => parse_log_level(&val).unwrap_or_else(|| { + eprintln!( + "Warning: Invalid BITFUN_LOG_LEVEL '{}', falling back to default", + val + ); + if is_debug { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + } + }), + Err(_) => { + if is_debug { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + } + } + } } pub fn parse_log_level(value: &str) -> Option { @@ -210,7 +228,7 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { let session_dir = config.session_log_dir.clone(); let use_stdout_only = is_embedded_webdriver_mode(); - if config.is_debug || use_stdout_only { + if config.is_debug { targets.push( Target::new(TargetKind::Stdout) .filter(|metadata| { @@ -219,33 +237,6 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { && !target.starts_with("webview") && !is_flashgrep_target(target) }) - .format(|out, message, record| { - let target = record.target(); - let simplified_target = if target.starts_with("webview:") { - "webview" - } else { - target - }; - - let (level_color, reset) = match record.level() { - log::Level::Error => ("\x1b[31m", "\x1b[0m"), // Red - log::Level::Warn => ("\x1b[33m", "\x1b[0m"), // Yellow - log::Level::Info => ("\x1b[32m", "\x1b[0m"), // Green - log::Level::Debug => ("\x1b[36m", "\x1b[0m"), // Cyan - log::Level::Trace => ("\x1b[90m", "\x1b[0m"), // Gray - }; - - out.finish(format_args!( - "[{}][tid:{}][{}{}{}][{}] {}", - chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%.3f"), - get_thread_id(), - level_color, - record.level(), - reset, - simplified_target, - message - )) - }), ); } @@ -262,7 +253,6 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { && !target.starts_with("webview") && !is_flashgrep_target(target) }) - .format(format_log_plain), ); let ai_log_dir = session_dir.clone(); @@ -272,7 +262,6 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { file_name: Some("ai".into()), }) .filter(|metadata| metadata.target().starts_with("ai")) - .format(format_log_plain), ); let flashgrep_log_dir = session_dir.clone(); @@ -282,7 +271,6 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { file_name: Some("flashgrep".into()), }) .filter(|metadata| is_flashgrep_target(metadata.target())) - .format(format_log_plain), ); let webview_log_dir = session_dir; @@ -292,7 +280,6 @@ pub fn build_log_targets(config: &LogConfig) -> Vec { file_name: Some("webview".into()), }) .filter(|metadata| metadata.target().starts_with("webview")) - .format(format_log_plain), ); } @@ -329,7 +316,6 @@ pub fn build_log_plugin(log_targets: Vec) -> TauriPlugin .rotation_strategy(RotationStrategy::KeepSome(2)) // 1 active + 2 backups .max_file_size(10 * 1024 * 1024) .timezone_strategy(TimezoneStrategy::UseLocal) - .clear_format() .build() } @@ -417,7 +403,7 @@ async fn do_cleanup_log_sessions( } pub fn spawn_log_cleanup_task() { - tokio::spawn(async { + tauri::async_runtime::spawn(async { cleanup_old_log_sessions().await; }); } diff --git a/src/apps/desktop/src/main.rs b/src/apps/desktop/src/main.rs index e910e3eec..0acd1d07f 100644 --- a/src/apps/desktop/src/main.rs +++ b/src/apps/desktop/src/main.rs @@ -1,11 +1,5 @@ // Hide console window in Windows release builds -#![cfg_attr( - all(not(debug_assertions), target_os = "windows"), - windows_subsystem = "windows" -)] - -#[tokio::main(flavor = "multi_thread", worker_threads = 4)] -async fn main() { - std::env::set_var("RUST_MIN_STACK", "8388608"); // 8MB - bitfun_desktop_lib::run().await +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +fn main() { + bitfun_desktop_lib::run() } diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 55c34cc50..2fca4665b 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -4,7 +4,6 @@ use std::sync::{OnceLock, RwLock}; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; -use dark_light::Mode; use log::{debug, error, warn}; use tauri::{Manager, WebviewUrl}; @@ -261,13 +260,7 @@ impl ThemeConfig { /// Maps config `themes.current` to a built-in id for splash / window chrome. /// `system` follows OS light/dark (aligned with web-ui `getSystemPreferredDefaultThemeId`). fn resolve_builtin_theme_id(theme_id: &str) -> &str { - if theme_id == "system" { - return match dark_light::detect() { - Mode::Dark => "bitfun-dark", - Mode::Light | Mode::Default => "bitfun-light", - }; - } - theme_id + "system" } pub fn generate_init_script(&self) -> String { @@ -349,11 +342,6 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { #[allow(unused_mut)] let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url) - .title("BitFun") - .inner_size(1200.0, 800.0) - .resizable(true) - .fullscreen(false) - .visible(false) .background_color(bg_color) .accept_first_mouse(true) .initialization_script(&init_script); @@ -482,19 +470,23 @@ fn agent_companion_window_effective_size(window: &tauri::WebviewWindow) -> tauri } fn position_agent_companion_window(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { - let Some(position) = remembered_agent_companion_window_position() - .or_else(|| agent_companion_default_position(app, window)) - else { - return; - }; + #[cfg(not(target_env = "ohos"))] + { + let Some(position) = remembered_agent_companion_window_position() + .or_else(|| agent_companion_default_position(app, window)) + else { + return; + }; - let size = agent_companion_window_effective_size(window); - let position = clamp_agent_companion_window_position(app, window, position, size); + warn!("Failed to position Agent companion window"); + let size = agent_companion_window_effective_size(window); + let position = clamp_agent_companion_window_position(app, window, position, size); - if let Err(e) = window.set_position(position) { - warn!("Failed to position Agent companion window: {}", e); - } else { - remember_agent_companion_window_position(position); + if let Err(e) = window.set_position(position) { + warn!("Failed to position Agent companion window: {}", e); + } else { + remember_agent_companion_window_position(position); + } } } @@ -504,116 +496,128 @@ fn resize_agent_companion_window( width: f64, height: f64, ) { - if !width.is_finite() || !height.is_finite() { - warn!( + #[cfg(not(target_env = "ohos"))] + { + if !width.is_finite() || !height.is_finite() { + warn!( "Ignored invalid Agent companion window size: width={}, height={}", width, height ); - return; - } + return; + } - let width = width.clamp( - AGENT_COMPANION_WINDOW_MIN_SIZE, - AGENT_COMPANION_WINDOW_MAX_WIDTH, - ); - let height = height.clamp( - AGENT_COMPANION_WINDOW_MIN_SIZE, - AGENT_COMPANION_WINDOW_MAX_HEIGHT, - ); - let scale_factor = window.scale_factor().unwrap_or(1.0); - let size = agent_companion_window_effective_size(window); - if (size.width - width).abs() < 0.5 && (size.height - height).abs() < 0.5 { - return; - } + let width = width.clamp( + AGENT_COMPANION_WINDOW_MIN_SIZE, + AGENT_COMPANION_WINDOW_MAX_WIDTH, + ); + let height = height.clamp( + AGENT_COMPANION_WINDOW_MIN_SIZE, + AGENT_COMPANION_WINDOW_MAX_HEIGHT, + ); + let scale_factor = window.scale_factor().unwrap_or(1.0); + let size = agent_companion_window_effective_size(window); + if (size.width - width).abs() < 0.5 && (size.height - height).abs() < 0.5 { + return; + } - let old_position = window - .outer_position() - .ok() - .map(|position| position.to_logical::(scale_factor)); + let old_position = window + .outer_position() + .ok() + .map(|position| position.to_logical::(scale_factor)); - if let Err(e) = window.set_size(tauri::LogicalSize::new(width, height)) { - warn!("Failed to resize Agent companion window: {}", e); - return; - } + if let Err(e) = window.set_size(tauri::LogicalSize::new(width, height)) { + warn!("Failed to resize Agent companion window: {}", e); + return; + } - // Keep the bottom-right corner fixed when bubbles change height. If we cannot - // read the previous geometry (e.g. transient platform errors), avoid snapping - // back to the default corner — that would feel like the pet "jumped". - if let Some(position) = old_position { - let next_position = clamp_agent_companion_window_position( - app, - window, - tauri::LogicalPosition::new( - position.x + size.width - width, - position.y + size.height - height, - ), - tauri::LogicalSize::new(width, height), - ); - if let Err(e) = window.set_position(next_position) { - warn!("Failed to position Agent companion window: {}", e); - } else { - remember_agent_companion_window_position(next_position); + // Keep the bottom-right corner fixed when bubbles change height. If we cannot + // read the previous geometry (e.g. transient platform errors), avoid snapping + // back to the default corner — that would feel like the pet "jumped". + if let Some(position) = old_position { + let next_position = clamp_agent_companion_window_position( + app, + window, + tauri::LogicalPosition::new( + position.x + size.width - width, + position.y + size.height - height, + ), + tauri::LogicalSize::new(width, height), + ); + if let Err(e) = window.set_position(next_position) { + warn!("Failed to position Agent companion window: {}", e); + } else { + remember_agent_companion_window_position(next_position); + } } + warn!("Failed to position Agent companion window") } } #[tauri::command] pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { - let _guard = agent_companion_window_ops().lock().await; - - // Reuse any existing window: never destroy here. A previous implementation destroyed - // whenever `is_visible` was false, which raced with another `show` that had built the - // window but not called `show()` yet (or with `hide`), producing duplicate pets or - // stuck windows. - if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { - if let Err(e) = window.unminimize() { - warn!("Failed to unminimize Agent companion window: {}", e); + #[cfg(not(target_env = "ohos"))] + { + let _guard = agent_companion_window_ops().lock().await; + + // Reuse any existing window: never destroy here. A previous implementation destroyed + // whenever `is_visible` was false, which raced with another `show` that had built the + // window but not called `show()` yet (or with `hide`), producing duplicate pets or + // stuck windows. + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + if let Err(e) = window.unminimize() { + warn!("Failed to unminimize Agent companion window: {}", e); + } + position_agent_companion_window(&app, &window); + window.show().map_err(|e| { + error!("Failed to show Agent companion window: {}", e); + format!("Failed to show Agent companion window: {}", e) + })?; + return Ok(()); } + + let url = app_url("?bitfunWindow=agent-companion"); + let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) + .title("BitFun Agent Companion") + .inner_size( + AGENT_COMPANION_WINDOW_MIN_SIZE, + AGENT_COMPANION_WINDOW_MIN_SIZE, + ) + .max_inner_size( + AGENT_COMPANION_WINDOW_MAX_WIDTH, + AGENT_COMPANION_WINDOW_MAX_HEIGHT, + ) + .min_inner_size(1.0, 1.0) + .resizable(false) + .decorations(false) + .transparent(true) + .always_on_top(true) + .skip_taskbar(true) + .shadow(false) + .visible(false) + .accept_first_mouse(true) + .background_color(tauri::window::Color(0, 0, 0, 0)); + + builder = builder.disable_drag_drop_handler(); + + let window = builder.build().map_err(|e| { + error!("Failed to create Agent companion window: {}", e); + format!("Failed to create Agent companion window: {}", e) + })?; + position_agent_companion_window(&app, &window); + window.show().map_err(|e| { error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; - return Ok(()); - } - - let url = app_url("?bitfunWindow=agent-companion"); - let mut builder = tauri::WebviewWindowBuilder::new(&app, AGENT_COMPANION_WINDOW_LABEL, url) - .title("BitFun Agent Companion") - .inner_size( - AGENT_COMPANION_WINDOW_MIN_SIZE, - AGENT_COMPANION_WINDOW_MIN_SIZE, - ) - .max_inner_size( - AGENT_COMPANION_WINDOW_MAX_WIDTH, - AGENT_COMPANION_WINDOW_MAX_HEIGHT, - ) - .min_inner_size(1.0, 1.0) - .resizable(false) - .decorations(false) - .transparent(true) - .always_on_top(true) - .skip_taskbar(true) - .shadow(false) - .visible(false) - .accept_first_mouse(true) - .background_color(tauri::window::Color(0, 0, 0, 0)); - - builder = builder.disable_drag_drop_handler(); - - let window = builder.build().map_err(|e| { - error!("Failed to create Agent companion window: {}", e); - format!("Failed to create Agent companion window: {}", e) - })?; - - position_agent_companion_window(&app, &window); - window.show().map_err(|e| { - error!("Failed to show Agent companion window: {}", e); - format!("Failed to show Agent companion window: {}", e) - })?; + Ok(()) + } + #[cfg(target_env = "ohos")] + { + Err("Failed to show Agent companion window".to_string()) + } - Ok(()) } #[tauri::command] @@ -622,73 +626,97 @@ pub async fn resize_agent_companion_desktop_pet( width: f64, height: f64, ) -> Result<(), String> { - let _guard = agent_companion_window_ops().lock().await; - if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { - let app_for_resize = app.clone(); - let window_for_resize = window.clone(); - window - .run_on_main_thread(move || { - resize_agent_companion_window(&app_for_resize, &window_for_resize, width, height); - }) - .map_err(|e| { - warn!("Failed to schedule Agent companion window resize: {}", e); - format!("Failed to schedule Agent companion window resize: {}", e) - })?; + #[cfg(target_env = "ohos")] + { + Err("Failed to resize Agent companion window".to_string()) } - Ok(()) + #[cfg(not(target_env = "ohos"))] + { + let _guard = agent_companion_window_ops().lock().await; + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + let app_for_resize = app.clone(); + let window_for_resize = window.clone(); + window + .run_on_main_thread(move || { + resize_agent_companion_window(&app_for_resize, &window_for_resize, width, height); + }) + .map_err(|e| { + warn!("Failed to schedule Agent companion window resize: {}", e); + format!("Failed to schedule Agent companion window resize: {}", e) + })?; + } + Ok(()) + } + } #[tauri::command] pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { - let _guard = agent_companion_window_ops().lock().await; - if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { - if let Ok(scale_factor) = window.scale_factor() { - if let Ok(position) = window.outer_position() { - remember_agent_companion_window_position(position.to_logical::(scale_factor)); + #[cfg(not(target_env = "ohos"))] + { + let _guard = agent_companion_window_ops().lock().await; + if let Some(window) = app.get_webview_window(AGENT_COMPANION_WINDOW_LABEL) { + if let Ok(scale_factor) = window.scale_factor() { + if let Ok(position) = window.outer_position() { + remember_agent_companion_window_position(position.to_logical::(scale_factor)); + } } + window.destroy().map_err(|e| { + error!("Failed to destroy Agent companion window: {}", e); + format!("Failed to destroy Agent companion window: {}", e) + })?; } - window.destroy().map_err(|e| { - error!("Failed to destroy Agent companion window: {}", e); - format!("Failed to destroy Agent companion window: {}", e) - })?; + Ok(()) } - Ok(()) + #[cfg(target_env = "ohos")] + { + Err("Failed to hide Agent companion window".to_string()) + } + } #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { - if let Some(main_window) = app.get_webview_window("main") { - #[cfg(target_os = "windows")] - { - // Work around Windows startup flicker: avoid creating the native window - // in maximized mode, and maximize it right before showing instead. - main_window.maximize().map_err(|e| { - error!("Failed to maximize main window: {}", e); - format!("Failed to maximize main window: {}", e) - })?; + #[cfg(not(target_env = "ohos"))] + { + if let Some(main_window) = app.get_webview_window("main") { + #[cfg(target_os = "windows")] + { + // Work around Windows startup flicker: avoid creating the native window + // in maximized mode, and maximize it right before showing instead. + main_window.maximize().map_err(|e| { + error!("Failed to maximize main window: {}", e); + format!("Failed to maximize main window: {}", e) + })?; + + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + } - tokio::time::sleep(std::time::Duration::from_millis(150)).await; - } + main_window.show().map_err(|e| { + error!("Failed to show main window: {}", e); + format!("Failed to show main window: {}", e) + })?; - main_window.show().map_err(|e| { - error!("Failed to show main window: {}", e); - format!("Failed to show main window: {}", e) - })?; + #[cfg(target_os = "macos")] + { + crate::cancel_main_window_close_request_on_macos(); + crate::mark_main_window_hidden_on_macos(false); + } - #[cfg(target_os = "macos")] - { - crate::cancel_main_window_close_request_on_macos(); - crate::mark_main_window_hidden_on_macos(false); + main_window.set_focus().map_err(|e| { + error!("Failed to focus main window: {}", e); + format!("Failed to focus main window: {}", e) + })?; + } else { + error!("Main window not found"); + return Err("Main window not found".to_string()); } - main_window.set_focus().map_err(|e| { - error!("Failed to focus main window: {}", e); - format!("Failed to focus main window: {}", e) - })?; - } else { - error!("Main window not found"); - return Err("Main window not found".to_string()); + Ok(()) + } + #[cfg(target_env = "ohos")] + { + Err("Failed to show main window".to_string()) } - Ok(()) } diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index b11c6b4bc..ea4120c54 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -1,60 +1,28 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "BitFun", - "identifier": "com.bitfun.desktop", - "build": { - "beforeDevCommand": "pnpm run dev:web", - "devUrl": "http://localhost:1422", - "beforeBuildCommand": "pnpm run build:web && pnpm run prepare:mobile-web", - "frontendDist": "../../../dist" - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/icon.icns", - "icons/icon.ico", - "icons/icon.png" - ], - "resources": { - "../../mobile-web/dist": "mobile-web/dist", - "resources/worker_host.js": "resources/worker_host.js" + "$schema": "https://schema.tauri.app/config/2", + "productName": "BitFun", + "identifier": "com.bitfun.desktop", + "build": { + "beforeDevCommand": "npm run dev:web", + "devUrl": "http://localhost:1422", + "frontendDist": "../../../dist" }, - "linux": { - "deb": { - "depends": [ - "libwebkit2gtk-4.1-0", - "libgtk-3-0" - ], - "files": { - "/usr/share/icons/hicolor/16x16/apps/bitfun-desktop.png": "icons/hicolor/16x16/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/32x32/apps/bitfun-desktop.png": "icons/hicolor/32x32/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/48x48/apps/bitfun-desktop.png": "icons/hicolor/48x48/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/64x64/apps/bitfun-desktop.png": "icons/hicolor/64x64/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/96x96/apps/bitfun-desktop.png": "icons/hicolor/96x96/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/128x128/apps/bitfun-desktop.png": "icons/hicolor/128x128/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/256x256/apps/bitfun-desktop.png": "icons/hicolor/256x256/apps/bitfun-desktop.png", - "/usr/share/icons/hicolor/512x512/apps/bitfun-desktop.png": "icons/hicolor/512x512/apps/bitfun-desktop.png" - }, - "postInstallScript": "scripts/post-install-icons.sh" - }, - "appimage": { - "bundleMediaFramework": false - } - } - }, - "app": { - "windows": [], - "security": { - "csp": null + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/icon.icns", + "icons/icon.ico" + ] }, - "macOSPrivateApi": true, - "withGlobalTauri": true - }, - "plugins": { - "updater": { - "endpoints": [], - "pubkey": "" + "app": { + "windows": [{ + "title": "tauri-demo", + "width": 800, + "height": 600 + }], + "security": { + "csp": null + } } - } -} +} \ No newline at end of file diff --git a/src/apps/ohos/.gitignore b/src/apps/ohos/.gitignore new file mode 100644 index 000000000..d2ff20141 --- /dev/null +++ b/src/apps/ohos/.gitignore @@ -0,0 +1,12 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer \ No newline at end of file diff --git a/src/apps/ohos/AppScope/app.json5 b/src/apps/ohos/AppScope/app.json5 new file mode 100644 index 000000000..e70e69703 --- /dev/null +++ b/src/apps/ohos/AppScope/app.json5 @@ -0,0 +1,11 @@ +{ + "app": { + "bundleName": "com.huawei.BitFun", + "vendor": "example", + "versionCode": 1000000, + "versionName": "1.0.0", + "buildVersion": "1", + "icon": "$media:bitfun_icon", + "label": "$string:app_name" + } +} diff --git a/src/apps/ohos/AppScope/resources/base/element/string.json b/src/apps/ohos/AppScope/resources/base/element/string.json new file mode 100644 index 000000000..9678373bb --- /dev/null +++ b/src/apps/ohos/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "BitFun" + } + ] +} diff --git a/src/apps/ohos/AppScope/resources/base/media/background.png b/src/apps/ohos/AppScope/resources/base/media/background.png new file mode 100644 index 000000000..923f2b3f2 Binary files /dev/null and b/src/apps/ohos/AppScope/resources/base/media/background.png differ diff --git a/src/apps/ohos/AppScope/resources/base/media/foreground.png b/src/apps/ohos/AppScope/resources/base/media/foreground.png new file mode 100644 index 000000000..eb9427585 Binary files /dev/null and b/src/apps/ohos/AppScope/resources/base/media/foreground.png differ diff --git a/src/apps/ohos/AppScope/resources/base/media/layered_image.json b/src/apps/ohos/AppScope/resources/base/media/layered_image.json new file mode 100644 index 000000000..fb4992044 --- /dev/null +++ b/src/apps/ohos/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/src/apps/ohos/build-profile.json5 b/src/apps/ohos/build-profile.json5 new file mode 100644 index 000000000..074b9ce93 --- /dev/null +++ b/src/apps/ohos/build-profile.json5 @@ -0,0 +1,56 @@ +{ + "app": { + "signingConfigs": [ + { + "name": "default", + "type": "HarmonyOS", + "material": { + "certpath": "C:\\Users\\lijiale\\.ohos\\config\\default_vcoder_mLoJ04E4ZMVs3NKJi5Yg30Rswoz227jnCV-I8k6sAvc=.cer", + "keyAlias": "debugKey", + "keyPassword": "0000001B1AC4E38D3159F409BFB38FF2B667CE1FE6E108A4C111EF2A0165222070DE0DE0656D86DB91D77C", + "profile": "C:\\Users\\lijiale\\.ohos\\config\\default_vcoder_mLoJ04E4ZMVs3NKJi5Yg30Rswoz227jnCV-I8k6sAvc=.p7b", + "signAlg": "SHA256withECDSA", + "storeFile": "C:\\Users\\lijiale\\.ohos\\config\\default_vcoder_mLoJ04E4ZMVs3NKJi5Yg30Rswoz227jnCV-I8k6sAvc=.p12", + "storePassword": "0000001BF771363E57B97E663BB05D7EF3F6EA63ED750CF7C726306B8714BD0DF072E44D384B69C6FE4F64" + } + } + ], + "products": [ + { + "name": "default", + "signingConfig": "default", + "targetSdkVersion": "6.1.0(23)", + "compatibleSdkVersion": "6.1.0(23)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug", + }, + { + "name": "release" + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/src/apps/ohos/code-linter.json5 b/src/apps/ohos/code-linter.json5 new file mode 100644 index 000000000..073990fa4 --- /dev/null +++ b/src/apps/ohos/code-linter.json5 @@ -0,0 +1,32 @@ +{ + "files": [ + "**/*.ets" + ], + "ignore": [ + "**/src/ohosTest/**/*", + "**/src/test/**/*", + "**/src/mock/**/*", + "**/node_modules/**/*", + "**/oh_modules/**/*", + "**/build/**/*", + "**/.preview/**/*" + ], + "ruleSet": [ + "plugin:@performance/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@security/no-unsafe-aes": "error", + "@security/no-unsafe-hash": "error", + "@security/no-unsafe-mac": "warn", + "@security/no-unsafe-dh": "error", + "@security/no-unsafe-dsa": "error", + "@security/no-unsafe-ecdsa": "error", + "@security/no-unsafe-rsa-encrypt": "error", + "@security/no-unsafe-rsa-sign": "error", + "@security/no-unsafe-rsa-key": "error", + "@security/no-unsafe-dsa-key": "error", + "@security/no-unsafe-dh-key": "error", + "@security/no-unsafe-3des": "error" + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/.gitignore b/src/apps/ohos/entry/.gitignore new file mode 100644 index 000000000..e2713a277 --- /dev/null +++ b/src/apps/ohos/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/src/apps/ohos/entry/build-profile.json5 b/src/apps/ohos/entry/build-profile.json5 new file mode 100644 index 000000000..0d5517e3e --- /dev/null +++ b/src/apps/ohos/entry/build-profile.json5 @@ -0,0 +1,38 @@ +{ + "apiType": "stageMode", + "buildOption": { + "externalNativeOptions": { + "path": "./src/main/cpp/CMakeLists.txt", + "arguments": "", + "cppFlags": "" + }, + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/src/apps/ohos/entry/hvigorfile.ts b/src/apps/ohos/entry/hvigorfile.ts new file mode 100644 index 000000000..b0e3a1ab9 --- /dev/null +++ b/src/apps/ohos/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/src/apps/ohos/entry/obfuscation-rules.txt b/src/apps/ohos/entry/obfuscation-rules.txt new file mode 100644 index 000000000..1e7e54e15 --- /dev/null +++ b/src/apps/ohos/entry/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/src/apps/ohos/entry/oh-package-lock.json5 b/src/apps/ohos/entry/oh-package-lock.json5 new file mode 100644 index 000000000..d09758325 --- /dev/null +++ b/src/apps/ohos/entry/oh-package-lock.json5 @@ -0,0 +1,34 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos-rs/ability@0.4.0-beta.0": "@ohos-rs/ability@0.4.0-beta.0", + "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib": "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib", + "libentry.so@src/main/cpp/types/libentry": "libentry.so@src/main/cpp/types/libentry" + }, + "packages": { + "@ohos-rs/ability@0.4.0-beta.0": { + "name": "@ohos-rs/ability", + "version": "0.4.0-beta.0", + "integrity": "sha512-3jXF0SzSqdyIEcWZy+2i/LWueVEFuLB9J3hYDiNDrL6guTMDqojMy5o9svD6pHEpfjnU+T7058bRTjGD2+iohA==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos-rs/ability/-/ability-0.4.0-beta.0.har", + "registryType": "ohpm" + }, + "libbitfun_desktop_lib.so@src/main/cpp/types/libbitfun_desktop_lib": { + "name": "libbitfun_desktop_lib.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libbitfun_desktop_lib", + "registryType": "local" + }, + "libentry.so@src/main/cpp/types/libentry": { + "name": "libentry.so", + "version": "1.0.0", + "resolved": "src/main/cpp/types/libentry", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/oh-package.json5 b/src/apps/ohos/entry/oh-package.json5 new file mode 100644 index 000000000..3fe6a279b --- /dev/null +++ b/src/apps/ohos/entry/oh-package.json5 @@ -0,0 +1,14 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": { + "libbitfun_desktop_lib.so": "file:./src/main/cpp/types/libbitfun_desktop_lib", + "libentry.so": "file:./src/main/cpp/types/libentry", + "@ohos-rs/ability": "0.4.0-beta.0" + } +} + diff --git a/src/apps/ohos/entry/src/main/cpp/CMakeLists.txt b/src/apps/ohos/entry/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..cadd21eef --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/CMakeLists.txt @@ -0,0 +1,15 @@ +# the minimum version of CMake. +cmake_minimum_required(VERSION 3.5.0) +project(MyApplication6) + +set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) + +if(DEFINED PACKAGE_FIND_FILE) + include(${PACKAGE_FIND_FILE}) +endif() + +include_directories(${NATIVERENDER_ROOT_PATH} + ${NATIVERENDER_ROOT_PATH}/include) + +add_library(entry SHARED napi_init.cpp) +target_link_libraries(entry PUBLIC libace_napi.z.so) \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/cpp/napi_init.cpp b/src/apps/ohos/entry/src/main/cpp/napi_init.cpp new file mode 100644 index 000000000..987bd48bd --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/napi_init.cpp @@ -0,0 +1,53 @@ +#include "napi/native_api.h" + +static napi_value Add(napi_env env, napi_callback_info info) +{ + size_t argc = 2; + napi_value args[2] = {nullptr}; + + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + + napi_valuetype valuetype0; + napi_typeof(env, args[0], &valuetype0); + + napi_valuetype valuetype1; + napi_typeof(env, args[1], &valuetype1); + + double value0; + napi_get_value_double(env, args[0], &value0); + + double value1; + napi_get_value_double(env, args[1], &value1); + + napi_value sum; + napi_create_double(env, value0 + value1, &sum); + + return sum; + +} + +EXTERN_C_START +static napi_value Init(napi_env env, napi_value exports) +{ + napi_property_descriptor desc[] = { + { "add", nullptr, Add, nullptr, nullptr, nullptr, napi_default, nullptr } + }; + napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); + return exports; +} +EXTERN_C_END + +static napi_module demoModule = { + .nm_version = 1, + .nm_flags = 0, + .nm_filename = nullptr, + .nm_register_func = Init, + .nm_modname = "entry", + .nm_priv = ((void*)0), + .reserved = { 0 }, +}; + +extern "C" __attribute__((constructor)) void RegisterEntryModule(void) +{ + napi_module_register(&demoModule); +} diff --git a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts new file mode 100644 index 000000000..8c9512b8f --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/Index.d.ts @@ -0,0 +1,2 @@ +export declare function registerArktsFunction(funcName: string, callback: ((err: Error | null, arg: string) => Promise)): void; +export declare function setBuildResult(msg: string): void; \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/oh-package.json5 b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/oh-package.json5 new file mode 100644 index 000000000..8b10ca321 --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/types/libbitfun_desktop_lib/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libbitfun_desktop_lib.so", + "types": "./Index.d.ts", + "version": "1.0.0", + "description": "Please describe the basic information." +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/cpp/types/libentry/Index.d.ts b/src/apps/ohos/entry/src/main/cpp/types/libentry/Index.d.ts new file mode 100644 index 000000000..e44f3615a --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/types/libentry/Index.d.ts @@ -0,0 +1 @@ +export const add: (a: number, b: number) => number; \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/cpp/types/libentry/oh-package.json5 b/src/apps/ohos/entry/src/main/cpp/types/libentry/oh-package.json5 new file mode 100644 index 000000000..ea410725a --- /dev/null +++ b/src/apps/ohos/entry/src/main/cpp/types/libentry/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libentry.so", + "types": "./Index.d.ts", + "version": "1.0.0", + "description": "Please describe the basic information." +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 000000000..a0ab8497e --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,269 @@ +import { + abilityAccessCtrl, + AbilityConstant, + common, + ConfigurationConstant, + Permissions, + Want +} from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { window } from '@kit.ArkUI'; +import { RustAbility } from '@ohos-rs/ability'; +import { CommonEventListener } from '../utils/CommonEventListener'; +import { harmonyShare, systemShare } from '@kit.ShareKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { uniformTypeDescriptor } from '@kit.ArkData'; +import { calendarManager } from '@kit.CalendarKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import RustModule from 'libbitfun_desktop_lib.so'; +import { CommonUtils } from '../utils/CommonUtils'; +import { runDeveco } from '../utils/DevecoStart'; + + +const DOMAIN = 0x0000; + +export default class EntryAbility extends RustAbility { + public moduleName: string = "bitfun_desktop_lib"; + public defaultPage: boolean = true; + public commonEventListener: CommonEventListener | undefined = undefined; + public remoteUrl: string = ""; + public shareStatus: boolean = false; + + async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise { + super.onCreate(want, launchParam); + this.commonEventListener = new CommonEventListener(); + } + + onDestroy(): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage): Promise { + AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context); + AppGlobal.mContext = this.context; + const permissions: Permissions[] = ['ohos.permission.READ_CALENDAR', 'ohos.permission.WRITE_CALENDAR']; + const DOMAIN_NUMBER: number = 0xFF00; + const TAG: string = '[BitfunUIAbilityComponents]'; + + let atManager = abilityAccessCtrl.createAtManager(); + AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context); + + atManager.requestPermissionsFromUser(this.context, permissions).then(() => { + AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context); + }).catch((error: BusinessError) => { + hilog.info(DOMAIN_NUMBER, TAG, 'get permissions error: ' + error); + }); + RustModule.registerArktsFunction('open_dialog_file', async (err: Error, arg: string): Promise => { + let res = await CommonUtils.open_file_dialog(); + return res; + }); + RustModule.registerArktsFunction('handle_min_window', async (err: Error, arg: string): Promise => { + windowStage.getMainWindowSync().minimize(); + return ''; + }); + RustModule.registerArktsFunction('handle_max_window', async (err: Error, arg: string): Promise => { + windowStage.getMainWindowSync().maximize(); + return ''; + }); + RustModule.registerArktsFunction('handle_restore_window', async (err: Error, arg: string): Promise => { + windowStage.getMainWindowSync().recover(); + return ''; + }); + RustModule.registerArktsFunction('window_is_maximized', async (err: Error, arg: string): Promise => { + let state = windowStage.getMainWindowSync().getWindowStatus(); + if (state == window.WindowStatusType.FULL_SCREEN || state == window.WindowStatusType.MAXIMIZE) { + return 'true'; + } else { + return 'false'; + } + }); + RustModule.registerArktsFunction('open_browser', async (err: Error, url: string): Promise => { + try { + let want : Want = { + "action": "ohos.want.action.viewData", + "entities": ["entity.system.browsable"], + "uri": url + } + await this.context.startAbility(want); + hilog.info(201, 'vnext', `open url succeed: ${url}`); + return `start ability succeed with ${url}`; + } catch (error) { + hilog.info(201, 'vnext', `fail to open ${url} ${error.code}`); + return `fail to open ${url} ${error.code}`; + } + }); + RustModule.registerArktsFunction('window_is_minimized', async (err: Error, arg: string): Promise => { + let state = windowStage.getMainWindowSync().getWindowStatus(); + if (state == window.WindowStatusType.MINIMIZE) { + return 'true'; + } else { + return 'false'; + } + }); + RustModule.registerArktsFunction('close_window', async (err: Error, arg: string): Promise => { + this.context.terminateSelf(() => { + hilog.info(DOMAIN_NUMBER, TAG, 'terminateSelf success'); + }); + return ''; + }); + RustModule.registerArktsFunction('window_start_dragging', async (err: Error, arg: string): Promise => { + windowStage.getMainWindowSync().startMoving(); + return ''; + }); + RustModule.registerArktsFunction('call_calendar', async (err: Error, arg: string): Promise => { + return await createMeetingEvent(arg); + }); + RustModule.registerArktsFunction('call_harmony_build', async (err: Error, arg: string): Promise => { + hilog.info(DOMAIN_NUMBER, TAG, 'register harmony build callback ' + arg); + runDeveco(this.context, arg); + return ''; + }); + RustModule.registerArktsFunction('send_remote_url', async (err: Error, arg: string): Promise => { + hilog.info(DOMAIN_NUMBER, TAG, 'get remote url ' + arg); + this.remoteUrl = arg; + if (this.remoteUrl.length == 0) { + this.shareDisablingListening(); + } + else { + this.shareListening(); + } + return ''; + }); + RustModule.registerArktsFunction('send_remote_dialog_status', async (err: Error, arg: string): Promise => { + hilog.info(DOMAIN_NUMBER, TAG, 'get remote dialog status ' + arg); + if (arg.length == 0) { + this.shareDisablingListening(); + } + else { + this.shareListening(); + } + return ''; + }); + RustModule.registerArktsFunction('harmony_create', async (err: Error, arg: string): Promise => { + await fileIo.copyDir('/storage/Users/currentUser/Documents/DevecoStudioProjects/MyApplication', + '/storage/Users/currentUser/Documents/files', 1).then(() => { + hilog.info(0x0000, 'vnext', 'copyDir success'); + }).catch((err: BusinessError) => { + hilog.info(0x0000, 'vnext', 'copyFile error: ' + JSON.stringify(err)); + }); + await fileIo.rename('/storage/Users/currentUser/Documents/files', + `/storage/Users/currentUser/Documents/DevecoStudioProjects/${arg.length == 0 ? 'MyApplication' : arg}`) + .then(() => { + hilog.info(0x0000, 'vnext', 'rename success'); + }).catch((err: BusinessError) => { + hilog.info(0x0000, 'vnext', 'rename error: ' + JSON.stringify(err)); + }); + return ''; + }); + setTimeout(() => { + windowStage.getMainWindow((err, data) => { + data.setWindowDecorHeight(44); + data.setWindowTitleMoveEnabled(false); + data.setWindowDecorVisible(false); + }) + }, 40) + RustModule.registerArktsFunction('reveal_in_explorer', async (err: Error, arg: string): Promise => { + const path = arg.replace('file://', 'file://docs'); + const want: Want = { + bundleName: 'com.huawei.hmos.filemanager', + abilityName: 'MainAbility', + parameters: { + fileUri: path + } + }; + const context = getContext(this) as common.UIAbilityContext; + try { + await context.startAbility(want) + } catch (err) { + hilog.error(201, 'vnext', `reveal in explorer failed ${JSON.stringify(err)}`) + } + return ''; + }); + RustModule.registerArktsFunction('set_theme_mode', async (err: Error, arg: string): Promise => { + if (arg === 'bitfun-light' || arg === 'bitfun-china-style') { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT) + } else { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK) + } + return ''; + }); + return super.onWindowStageCreate(windowStage); + } + + private shareListening() { + hilog.info(0x0000, 'vnext', 'shareListening'); + if (this.remoteUrl.length != 0 && !this.shareStatus) { + harmonyShare.on('knockShare', this.sendOnlyCallback); + this.shareStatus = true; + } + } + private shareDisablingListening() { + harmonyShare.off('knockShare', this.sendOnlyCallback); + this.shareStatus = false; + } + private sendOnlyCallback = (sharableTable: harmonyShare.SharableTarget) => { + if (this.remoteUrl.length != 0) { + let content = this.remoteUrl; + let shareData: systemShare.SharedData = new systemShare.SharedData({ + utd: uniformTypeDescriptor.UniformDataType.HYPERLINK, + content, + title: "BitFun", + description: "Phone", + }); + sharableTable.share(shareData) + } else { + hilog.error(DOMAIN, 'vnext', 'sendOnlyCallback error: remote url is empty'); + } + } +} + +interface AppGlobalType { + calendarMgr: calendarManager.CalendarManager | null, + mContext: common.UIAbilityContext | null +} + +export const AppGlobal: AppGlobalType = { + calendarMgr: null, + mContext: null +} + +export async function createMeetingEvent(input: string): Promise { + interface CalendarInfo { + title: string, + description: string, + startTime: string, + endTime: string + } + + const info: CalendarInfo = JSON.parse(input) + const calendarAccount: calendarManager.CalendarAccount = { + name: info.title, + type: calendarManager.CalendarType.LOCAL, + displayName: 'vcoder', + }; + const startTime = new Date(info.startTime).valueOf(); + + const time = info.endTime == '' ? startTime : info.endTime; + const endTime = new Date(time).valueOf(); + + const event: calendarManager.Event = { + type: calendarManager.EventType.NORMAL, + title: info.title, + startTime: startTime, + endTime: endTime, + isAllDay: false, + description: info.description + }; + + try { + let data: calendarManager.Calendar | undefined = await AppGlobal.calendarMgr?.createCalendar(calendarAccount); + if (!data || data === null) { + hilog.warn(0x0000, 'vnext', 'Failed to create calendar, data is null'); + return "" + } + let id = await data.addEvent(event); + return "succeed in creating calendar and event " + id; + } catch (error) { + return "Failed to create calendar or event. Code: " + error; + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/src/apps/ohos/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 000000000..8e4de9928 --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -0,0 +1,16 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit'; + +const DOMAIN = 0x0000; + +export default class EntryBackupAbility extends BackupExtensionAbility { + async onBackup() { + hilog.info(DOMAIN, 'testTag', 'onBackup ok'); + await Promise.resolve(); + } + + async onRestore(bundleVersion: BundleVersion) { + hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); + await Promise.resolve(); + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/pages/Index.ets b/src/apps/ohos/entry/src/main/ets/pages/Index.ets new file mode 100644 index 000000000..8e2d24ad4 --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/pages/Index.ets @@ -0,0 +1,23 @@ +@Entry +@Component +struct Index { + @State message: string = 'Hello World'; + + build() { + RelativeContainer() { + Text(this.message) + .id('HelloWorld') + .fontSize($r('app.float.page_text_font_size')) + .fontWeight(FontWeight.Bold) + .alignRules({ + center: { anchor: '__container__', align: VerticalAlign.Center }, + middle: { anchor: '__container__', align: HorizontalAlign.Center } + }) + .onClick(() => { + this.message = 'Welcome'; + }) + } + .height('100%') + .width('100%') + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/utils/CommonEventListener.ets b/src/apps/ohos/entry/src/main/ets/utils/CommonEventListener.ets new file mode 100644 index 000000000..302482185 --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/CommonEventListener.ets @@ -0,0 +1,43 @@ +import commonEventManager from "@ohos.commonEventManager"; +import { hilog } from "@kit.PerformanceAnalysisKit"; +import Base from '@ohos.base'; +import NativeModule from 'libbitfun_desktop_lib.so'; + +export class CommonEventListener { + protected subscriber: commonEventManager.CommonEventSubscriber | null = null; + protected subscriberInfo: commonEventManager.CommonEventSubscribeInfo = { + events: ["DEVECO_BUILD_END"] + }; + + constructor() { + hilog.info(0x0000, 'vnext', 'create CommonEventListener'); + this.subscriber = commonEventManager.createSubscriberSync(this.subscriberInfo); + if (this.subscriber !== null) { + commonEventManager.subscribe(this.subscriber, (err: Base.BusinessError, data: commonEventManager.CommonEventData) => { + if (err) { + hilog.error(0x0000, 'vnext', 'receive message error: ' + err); + return; + } + + hilog.info(0x0000, 'vnext', 'receive data ' + JSON.stringify(data)); + switch (data.event) { + case "DEVECO_BUILD_END": + let msg = getMessageParam(data, "msg"); + hilog.info(0x0000, 'vnext', 'receive message DEVECO_BUILD_END: ' + msg); + NativeModule.setBuildResult(msg); + } + }) + } else { + hilog.error(0x0000, 'vnext', 'subscriber is null!'); + } + } +} + +export function getMessageParam(data: commonEventManager.CommonEventData, key: string): T | undefined { + let value: T | undefined = undefined; + let param = data.parameters; + if (param) { + value = param[key]; + } + return value; +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/utils/CommonUtils.ets b/src/apps/ohos/entry/src/main/ets/utils/CommonUtils.ets new file mode 100644 index 000000000..d93a097ca --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/CommonUtils.ets @@ -0,0 +1,36 @@ +import { bundleManager } from "@kit.AbilityKit"; +import hilog from "@ohos.hilog"; +import filePicker from '@ohos.file.picker'; +import fileUri from "@ohos.file.fileuri"; +import { Err, Ok, Result } from "./Result"; + +export class CommonUtils { + static getBundleName(): string { + let bundleName = ""; + try { + bundleName = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).name; + } catch (e) { + hilog.warn(0x0000, 'vnext', 'getBundleName failed' + e); + } + return bundleName; + } + + static async open_file_dialog(): Promise { + try { + hilog.info(0x0000, 'vnext', 'open_file_dialog'); + let documentOptions = new filePicker.DocumentSelectOptions; + documentOptions.defaultFilePathUri = 'file://docs/storage/Users/currentUser'; + documentOptions.selectMode = filePicker.DocumentSelectMode.MIXED; + documentOptions.maxSelectNumber = 1; + let documentPicker = new filePicker.DocumentViewPicker(); + hilog.info(0x0000, 'vnext', 'open filePicker success'); + let select = await documentPicker.select(documentOptions); + let fileUrl = new fileUri.FileUri(select.pop() as string); + let tmp = fileUrl.path.replace('/storage/Users/currentUser/appdata', '/data/storage'); + let uri = tmp.replace(CommonUtils.getBundleName(), 'base'); + return uri + } catch (e) { + return 'open file or folder failed'; + } + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/utils/DevecoStart.ets b/src/apps/ohos/entry/src/main/ets/utils/DevecoStart.ets new file mode 100644 index 000000000..9b0701abe --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/DevecoStart.ets @@ -0,0 +1,78 @@ +import common from "@ohos.app.ability.common"; +import { JSON } from "@kit.ArkTS"; +import hilog from "@ohos.hilog"; +import Want from "@ohos.app.ability.Want"; +import { CommonUtils } from "./CommonUtils"; +import { fileIo } from "@kit.CoreFileKit"; + +let srcDirPathLocal: string = + '/storage/Users/currentUser/Documents/DevecoStudioProjects/MyApplication/build-profile.json5' + +export function runDeveco(context: common.UIAbilityContext, workspace: string): void { + let args = defaultStartArgs(); + args.workspace = workspace; + args.build_after_start = true; + startNewInstance(context, args) +} + +export function copySignFile(path: string): void { + let dstDirPathLocal: string = path + "/build-profile.json5"; + try { + fileIo.copyFile(srcDirPathLocal, dstDirPathLocal, 0).then(() => { + hilog.info(0x0000, 'vnext', 'copyFile success '); + }).catch((err: BusinessError) => { + hilog.info(0x0000, 'vnext', 'copyFile error: ' + JSON.stringify(err)); + }) + } catch (err) { + hilog.info(0x0000, 'vnext', 'copyFile error: ' + JSON.stringify(err)); + } +} + +export function startNewInstance(context: common.UIAbilityContext, args: StartArgs | undefined): void { + hilog.info(0x0000, 'vnext', 'startNewInstance ' + JSON.stringify(args)); + const want: Want = { + bundleName: 'com.huawei.devecostudio', + abilityName: 'EntryAbility', + parameters: { + 'startParameters': JSON.stringify(args), + } + }; + context.startAbility(want).then(() => { + hilog.info(0x0000, 'vnext', 'startAbility success '); + }).catch((error: BusinessError) => { + hilog.warn(0x0000, 'vnext', `startAbility failed: code: ${error.code}, msg: ${error.message} `); + }); + +} + +export interface StartArgs { + workspace: string, + opened_files: string, + window_state: WindowState, + x: number | undefined, + y: number | undefined, + width: number | undefined, + height: number | undefined, + restart: boolean, + build_after_start: boolean +} + +export enum WindowState { + Restored = "Restored", + Maximized = "Maximized", + Minimized = "Minimized" +} + +export function defaultStartArgs(): StartArgs { + return { + workspace: "", + opened_files: "", + window_state: WindowState.Restored, + x: undefined, + y: undefined, + width: undefined, + height: undefined, + restart: false, + build_after_start: false + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/ets/utils/Result.ets b/src/apps/ohos/entry/src/main/ets/utils/Result.ets new file mode 100644 index 000000000..eb405f2e9 --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/Result.ets @@ -0,0 +1,20 @@ +export interface ResultInner { + Ok?: T; + Err?: E; +} + +export class Result { + Ok?: T; + Err?: E; + constructor(result: ResultInner) { + this.Ok = result.Ok; + this.Err = result.Err; + } +} +export function Ok(ok?: T): Result { + return new Result({ Ok: ok }); +} + +export function Err(err?: E): Result { + return new Result({ Err: err }); +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/module.json5 b/src/apps/ohos/entry/src/main/module.json5 new file mode 100644 index 000000000..5d753164e --- /dev/null +++ b/src/apps/ohos/entry/src/main/module.json5 @@ -0,0 +1,112 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:bitfun_icon", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + } + ] + } + ], + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.READ_WRITE_USER_FILE" + }, + { + "name": "ohos.permission.CUSTOM_SANDBOX" + }, + { + "name": "ohos.permission.READ_CALENDAR", + "reason": "$string:module_desc", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } + }, + { + "name": "ohos.permission.WRITE_CALENDAR", + "reason": "$string:module_desc", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } + }, + { + "name": "ohos.permission.READ_WRITE_DESKTOP_DIRECTORY", + "reason": "$string:module_desc", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } + }, + { + "name": "ohos.permission.READ_WRITE_DOCUMENTS_DIRECTORY", + "reason": "$string:module_desc", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } + }, + { + "name": "ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY", + "reason": "$string:module_desc", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } + } + ], + "extensionAbilities": [ + { + "name": "EntryBackupAbility", + "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", + "type": "backup", + "exported": false, + "metadata": [ + { + "name": "ohos.extension.backup", + "resource": "$profile:backup_config" + } + ], + } + ] + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/base/element/color.json b/src/apps/ohos/entry/src/main/resources/base/element/color.json new file mode 100644 index 000000000..3c712962d --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/base/element/float.json b/src/apps/ohos/entry/src/main/resources/base/element/float.json new file mode 100644 index 000000000..33ea22304 --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/element/float.json @@ -0,0 +1,8 @@ +{ + "float": [ + { + "name": "page_text_font_size", + "value": "50fp" + } + ] +} diff --git a/src/apps/ohos/entry/src/main/resources/base/element/string.json b/src/apps/ohos/entry/src/main/resources/base/element/string.json new file mode 100644 index 000000000..fc8fbb64e --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/element/string.json @@ -0,0 +1,15 @@ +{ + "string": [{ + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "BitFun" + } + ] +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/base/media/background.png b/src/apps/ohos/entry/src/main/resources/base/media/background.png new file mode 100644 index 000000000..923f2b3f2 Binary files /dev/null and b/src/apps/ohos/entry/src/main/resources/base/media/background.png differ diff --git a/src/apps/ohos/entry/src/main/resources/base/media/bitfun_icon.png b/src/apps/ohos/entry/src/main/resources/base/media/bitfun_icon.png new file mode 100644 index 000000000..29f7221d0 Binary files /dev/null and b/src/apps/ohos/entry/src/main/resources/base/media/bitfun_icon.png differ diff --git a/src/apps/ohos/entry/src/main/resources/base/media/foreground.png b/src/apps/ohos/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 000000000..97014d3e1 Binary files /dev/null and b/src/apps/ohos/entry/src/main/resources/base/media/foreground.png differ diff --git a/src/apps/ohos/entry/src/main/resources/base/media/layered_image.json b/src/apps/ohos/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 000000000..fb4992044 --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/base/media/startIcon.png b/src/apps/ohos/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 000000000..205ad8b5a Binary files /dev/null and b/src/apps/ohos/entry/src/main/resources/base/media/startIcon.png differ diff --git a/src/apps/ohos/entry/src/main/resources/base/profile/backup_config.json b/src/apps/ohos/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 000000000..78f40ae7c --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/base/profile/main_pages.json b/src/apps/ohos/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 000000000..1898d94f5 --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,5 @@ +{ + "src": [ + "pages/Index" + ] +} diff --git a/src/apps/ohos/entry/src/main/resources/dark/element/color.json b/src/apps/ohos/entry/src/main/resources/dark/element/color.json new file mode 100644 index 000000000..79b11c274 --- /dev/null +++ b/src/apps/ohos/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + } + ] +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/mock/mock-config.json5 b/src/apps/ohos/entry/src/mock/mock-config.json5 new file mode 100644 index 000000000..7a73a41bf --- /dev/null +++ b/src/apps/ohos/entry/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/ohosTest/ets/test/Ability.test.ets b/src/apps/ohos/entry/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 000000000..85c78f675 --- /dev/null +++ b/src/apps/ohos/entry/src/ohosTest/ets/test/Ability.test.ets @@ -0,0 +1,35 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function abilityTest() { + describe('ActsAbilityTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }) + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }) + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }) + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }) + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }) + }) +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/ohosTest/ets/test/List.test.ets b/src/apps/ohos/entry/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 000000000..794c7dc4e --- /dev/null +++ b/src/apps/ohos/entry/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/ohosTest/module.json5 b/src/apps/ohos/entry/src/ohosTest/module.json5 new file mode 100644 index 000000000..b94766632 --- /dev/null +++ b/src/apps/ohos/entry/src/ohosTest/module.json5 @@ -0,0 +1,12 @@ +{ + "module": { + "name": "entry_test", + "type": "feature", + "deviceTypes": [ + "phone", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false + } +} diff --git a/src/apps/ohos/entry/src/test/List.test.ets b/src/apps/ohos/entry/src/test/List.test.ets new file mode 100644 index 000000000..bb5b5c373 --- /dev/null +++ b/src/apps/ohos/entry/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/src/apps/ohos/entry/src/test/LocalUnit.test.ets b/src/apps/ohos/entry/src/test/LocalUnit.test.ets new file mode 100644 index 000000000..165fc1615 --- /dev/null +++ b/src/apps/ohos/entry/src/test/LocalUnit.test.ets @@ -0,0 +1,33 @@ +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function localUnitTest() { + describe('localUnitTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }); + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }); + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }); + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }); + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }); + }); +} \ No newline at end of file diff --git a/src/apps/ohos/hvigor/hvigor-config.json5 b/src/apps/ohos/hvigor/hvigor-config.json5 new file mode 100644 index 000000000..20f88f34a --- /dev/null +++ b/src/apps/ohos/hvigor/hvigor-config.json5 @@ -0,0 +1,23 @@ +{ + "modelVersion": "6.1.0", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + // "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/src/apps/ohos/hvigorfile.ts b/src/apps/ohos/hvigorfile.ts new file mode 100644 index 000000000..47113e2e3 --- /dev/null +++ b/src/apps/ohos/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/src/apps/ohos/oh-package-lock.json5 b/src/apps/ohos/oh-package-lock.json5 new file mode 100644 index 000000000..c5a91ec3c --- /dev/null +++ b/src/apps/ohos/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.25": { + "name": "@ohos/hypium", + "version": "1.0.25", + "integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/src/apps/ohos/oh-package.json5 b/src/apps/ohos/oh-package.json5 new file mode 100644 index 000000000..41eb3e0c5 --- /dev/null +++ b/src/apps/ohos/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "modelVersion": "6.1.0", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": { + "@ohos/hypium": "1.0.25", + "@ohos/hamock": "1.0.0" + } +} diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index b36ca3a1b..c649f91a9 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -12,6 +12,8 @@ crate-type = ["rlib"] [dependencies] # Inherit shared dependencies from workspace tokio = { workspace = true } +napi-ohos = { workspace = true } +napi-derive-ohos = { workspace = true } tokio-stream = { workspace = true } tokio-util = { workspace = true } async-trait = { workspace = true } @@ -38,7 +40,9 @@ aes = "0.8" hex = "0.4" dashmap = { workspace = true } indexmap = { workspace = true } - +lazy_static = "1.4" +once_cell = "1.19" +parking_lot = "0.12" reqwest = { workspace = true } # Debug Log HTTP Server @@ -116,7 +120,6 @@ sha2 = { workspace = true } rand = { workspace = true } # Device/Network info (Remote Connect) -mac_address = { workspace = true } local-ip-address = { workspace = true } hostname = { workspace = true } @@ -145,7 +148,7 @@ bitfun-runtime-ports = { path = "../runtime-ports" } bitfun-transport = { path = "../transport" } # Tauri dependency (optional, enabled only when needed) -tauri = { workspace = true, optional = true } +tauri = { workspace = true } # Non-Windows: vendored OpenSSL for libgit2 (no system install). [target.'cfg(not(windows))'.dependencies] @@ -162,7 +165,7 @@ schannel = "0.1" # explicitly before `bitfun-core` default features are made lighter. default = ["product-full"] product-full = ["ssh-remote"] -tauri-support = ["tauri"] # Optional tauri support +tauri-support = [] # Optional tauri support ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] # russh-keys pure-Rust crypto backend (no openssl) [build-dependencies] diff --git a/src/crates/core/src/agentic/tools/computer_use_host.rs b/src/crates/core/src/agentic/tools/computer_use_host.rs index e8f83c1b6..f6c17a8b3 100644 --- a/src/crates/core/src/agentic/tools/computer_use_host.rs +++ b/src/crates/core/src/agentic/tools/computer_use_host.rs @@ -1434,7 +1434,7 @@ pub enum AppWaitPredicate { /// `interaction_state.displays` so it can pick the right screen explicitly /// instead of falling back to whichever screen the mouse pointer happens /// to be on (the original "computer use 在多屏时搞错操作的屏幕" failure mode). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct ComputerUseDisplayInfo { /// Stable per-session id of the display. Pass back to /// [`ComputerUseHost::focus_display`] to pin subsequent screenshots / diff --git a/src/crates/core/src/agentic/tools/implementations/calendar_tool.rs b/src/crates/core/src/agentic/tools/implementations/calendar_tool.rs new file mode 100644 index 000000000..8d0d5d678 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/calendar_tool.rs @@ -0,0 +1,155 @@ +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::util::errors::BitFunResult; +use crate::util::JS_THREADSAFE_FUNCTION; +use async_trait::async_trait; +use serde_json::{ Value,json}; + +pub struct CalendarTool; + +impl CalendarTool { + pub fn new() -> CalendarTool { + Self + } +} + +#[async_trait] +impl Tool for CalendarTool { + fn name(&self) -> &str { + "Calendar" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Manages all types of calendar schedules, including events, reminders, deadlines, and all-day entries. + + Usage Guidelines: + - Supported actions: 'create' (new entry) + - You MUST extract the specific city or venue into the 'location' field (e.g, 'Beijing'). + - DO NOT leave the primary location only inside the 'description' or 'title'. + - Time Format: Always use 'YYYY-MM-DD HH:mm'. + - Participants can include names or email address, Leave empty for personal tasks. + "#.to_string()) + } + + fn short_description(&self) -> String { + "Operate the calendar in ohos".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Short title of the schedule (e.g., 'Flight to Tokyo'. 'Dentist Appointment')", + }, + "description": { + "type": "string", + "description": "Detailed notes or additional information" + }, + "start_time": { + "type": "string", + "description": "YYYY-MM-DD HH:mm format" + }, + "end_time": { + "type": "string", + "description": "YYYY-MM-DD HH:mm" + }, + "location": { + "type": "string", + "description": "The specific physical location, city, or address. E.G., 'Beijing' or 'Forbidden City'", + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext + )-> BitFunResult> { + let title = input.get("title").and_then(|v| v.as_str()).unwrap_or_default(); + let description = input.get("description").and_then(|v| v.as_str()).unwrap_or_default(); + let start_time = input.get("start_time").and_then(|v| v.as_str()).unwrap_or_default(); + let end_time = input.get("end_time").and_then(|v| v.as_str()).unwrap_or_default(); + let info = CalendarInfo::new(title.to_string(), description.to_string(), start_time.to_string(), end_time.to_string()); + + let res = call_calender(serde_json::to_string(&info).unwrap_or_default()); + let action = "创建日程"; + + let result = ToolResult::Result { + data: json!({ + "action": action, + "success": true + }), + result_for_assistant: Some(format!( + "Calendar {} operation executed successfully", + action + )), + image_attachments: None, + }; + Ok(vec![result]) + } +} + +#[napi(object)] +#[derive(Debug,Clone,Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CalendarInfo { + pub title: String, + pub start_time: String, + pub end_time: String, + pub description: String, +} + +impl CalendarInfo { + pub fn new(title: String, start_time: String, end_time: String, description: String) -> Self { + Self { + title, + start_time, + end_time, + description + } + } +} + +use napi_derive_ohos::napi; +use serde::Serialize; + +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; + +pub fn call_calender(args: String) -> Result{ + let result = Ok(args); + match JS_THREADSAFE_FUNCTION.write().get("call_calender") { + None => { + return Err("The Arkts has not register the functions".to_string()); + } + Some(functions) => { + functions.call_with_return_value( + result, + ThreadsafeFunctionCallMode::Blocking, + move |result,_| { + match result { + Ok(_) => { + log::info!("Successfully called Arkts"); + } + Err(err) => { + log::error!("call calender with error {:?}", err); + } + } + Ok(()) + } + ); + } + } + Ok("".to_string()) +} \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/implementations/harmony_build_tool.rs b/src/crates/core/src/agentic/tools/implementations/harmony_build_tool.rs new file mode 100644 index 000000000..1fd58fea8 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/harmony_build_tool.rs @@ -0,0 +1,250 @@ +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use napi_derive_ohos::napi; +use parking_lot::{Condvar, Mutex}; +use serde_json::{json, Value}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use crate::util::JS_THREADSAFE_FUNCTION; +struct BuildState { + result: Option, + notified: bool, +} + +static BUILD_STATE: once_cell::sync::Lazy, Condvar)>> = + once_cell::sync::Lazy::new(|| { + Arc::new(( + Mutex::new(BuildState { + result: None, + notified: false, + }), + Condvar::new(), + )) + }); + +pub struct HarmonyBuildTool {} + +impl HarmonyBuildTool { + pub fn new() -> Self { + Self {} + } + + fn validate_project_path(&self, project_path: &str) -> bool { + let path = Path::new(project_path); + path.exists() && path.is_dir() + } + + async fn execute_build(&self, project_path: &str) -> BitFunResult { + log::info!("HarmonyOS build for project: {}", project_path); + + { + let (lock, cvar) = &**BUILD_STATE; + let mut state = lock.lock(); + state.result = None; + state.notified = false; + cvar.notify_all(); + } + + match call_harmony_build(project_path.to_string()) { + Ok(_) => { + log::info!("call_harmony_build success"); + let timeout = Duration::from_secs(60); + let (lock, cvar) = &**BUILD_STATE; + let mut state = lock.lock(); + + let wait_result = cvar.wait_for(&mut state, timeout); + if !wait_result.timed_out() && state.notified { + if let Some(msg) = &state.result { + log::info!("Build result received: {}", msg); + return Ok(msg.clone()); + } + } + + log::error!("Build timeout"); + Err(BitFunError::tool( + "Build timeout: no result received within 1 minute".to_string(), + )) + } + Err(_) => { + log::error!("call_harmony_build failed"); + Err(BitFunError::tool( + "Build failed: call_harmony_build failed".to_string(), + )) + } + } + } +} + +#[async_trait] +impl Tool for HarmonyBuildTool { + fn name(&self) -> &str { + "HarmonyBuild" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"HarmonyOS application build tool. Builds a HarmonyOS project. + + Usage: + - The project_path parameter must be an absolute path to a HarmonyOS project + + Example: + - Build project: {"project_path": "path/to/harmony/project"}"# + .to_string(), + ) + } + + fn short_description(&self) -> String { + "Build the harmony project in ohos".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "project_path": { + "type": "string", + "description": "The absolute path to the HarmonyOS project" + } + }, + "required": [ "project_path" ], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + true + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let project_path = match input.get("project_path").and_then(|v| v.as_str()) { + Some(path) => path, + None => { + return ValidationResult { + result: false, + message: Some("project_path is required".to_string()), + error_code: Some(400), + meta: None, + }; + } + }; + + if project_path.is_empty() { + return ValidationResult { + result: false, + message: Some("project_path cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + if !self.validate_project_path(project_path) { + return ValidationResult { + result: false, + message: Some(format!( + "Project path does not exist or is not a directory: {}", + project_path + )), + error_code: Some(404), + meta: None, + }; + } + + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, options: &ToolRenderOptions) -> String { + let project_path = input + .get("project_path") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if options.verbose { + format!("HarmmonyOS build on project: {}", project_path) + } else { + format!("HarmonyOS build: {}", project_path) + } + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let project_path = input + .get("project_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("project_path is required".to_string()))?; + + let result = self.execute_build(project_path).await?; + + Ok(vec![ToolResult::Result { + data: json!({ + "project_path": project_path, + "success": true + }), + result_for_assistant: Some(result), + image_attachments: None, + }]) + } +} +#[napi] +pub fn set_build_result(msg: String) { + log::info!("set_build_result msg: {}", msg); + let (lock, cvar) = &**BUILD_STATE; + let mut state = lock.lock(); + state.result = Some(msg); + state.notified = true; + cvar.notify_all(); +} +pub fn call_harmony_build(args: String) -> Result { + let result = Ok(args); + let results = Arc::new(Mutex::new(String::default())); + match JS_THREADSAFE_FUNCTION.write().get("call_harmony_build") { + None => { + log::error!("call_harmony_build has not register"); + Err("The Arkts has not register the function".to_owned()) + } + Some(function) => { + function.call_with_return_value( + result, + ThreadsafeFunctionCallMode::Blocking, + move |result, _| { + match result { + Ok(_) => { + log::info!("call_harmony_build successfully"); + } + Err(err) => { + log::error!("call_harmony_build failed with error: {}", err); + } + } + Ok(()) + }, + ); + let res = results.lock().to_string(); + Ok(res) + } + } +} \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/implementations/harmonyos_project.rs b/src/crates/core/src/agentic/tools/implementations/harmonyos_project.rs new file mode 100644 index 000000000..eca0a4d1e --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/harmonyos_project.rs @@ -0,0 +1,104 @@ +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::util::errors::BitFunResult; +use crate::util::JS_THREADSAFE_FUNCTION; +use async_trait::async_trait; +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; +use serde_json::{json, Value}; + +pub struct HarmonyProjectGenTool; + +impl HarmonyProjectGenTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for HarmonyProjectGenTool { + fn name(&self) -> &str { + "HarmonyGenerate" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Generates or create a new HarmonyOS project. + Usages: + - Use this to scaffold a new HarmonyOS/OpenHarmony project using ArkTs."# + .to_string()) + } + + fn short_description(&self) -> String { + "Create the project in the ohos".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "HarmonyOS/OpenHarmony Project Name" + }, + }, + "required": ["name"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let title = input + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let _ = harmonyos_create(title.to_string()); + let result = ToolResult::Result { + data: json!({ + "project_name": title, + "bundle_name": "com.example.myapplication", + "status": "created", + }), + result_for_assistant: Some("Successfully generated HarmonyOS project".to_string()), + image_attachments: None, + }; + Ok(vec![result]) + } +} + +pub fn harmonyos_create(title: String) -> Result { + let result = Ok(title); + + match JS_THREADSAFE_FUNCTION.write().get("harmonyos_create") { + None => { + return Err(String::from("harmonyos_create is not defined")); + } + Some(functions) => { + functions.call_with_return_value( + result, + ThreadsafeFunctionCallMode::Blocking, + move |result, _| { + match result { + Ok(_) => { + log::info!("harmonyos_create is created"); + } + Err(error) => { + log::error!("harmonyos_create error: {:?}", error); + } + } + Ok(()) + }, + ); + } + } + Ok("".to_string()) +} diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 6436bcb83..37b78aa67 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -2,6 +2,8 @@ pub mod ask_user_question_tool; pub mod bash_tool; +pub mod calendar_tool; +pub mod harmonyos_project; pub mod code_review_tool; pub mod computer_use_actions; pub mod computer_use_input; @@ -40,7 +42,7 @@ pub mod todo_write_tool; pub mod get_tool_spec_tool; pub mod util; pub mod web_tools; - +pub mod harmony_build_tool; pub use ask_user_question_tool::AskUserQuestionTool; pub use bash_tool::BashTool; pub use code_review_tool::CodeReviewTool; diff --git a/src/crates/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/core/src/infrastructure/app_paths/path_manager.rs index 04263a9de..f7af53a27 100644 --- a/src/crates/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/core/src/infrastructure/app_paths/path_manager.rs @@ -32,6 +32,7 @@ pub enum StorageLevel { pub struct PathManager { /// User config root directory user_root: PathBuf, + home_dir: PathBuf, /// Optional override for the BitFun home directory, used by tests to avoid /// touching the real user home. bitfun_home_override: Option, @@ -43,9 +44,11 @@ impl PathManager { /// Create a new path manager pub fn new() -> BitFunResult { let user_root = Self::get_user_config_root()?; + let home_dir = Self::get_home_dir()?; Ok(Self { user_root, + home_dir, bitfun_home_override: None, project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), }) @@ -57,20 +60,20 @@ impl PathManager { /// - macOS: ~/Library/Application Support/BitFun/ /// - Linux: ~/.config/bitfun/ fn get_user_config_root() -> BitFunResult { - let config_dir = dirs::config_dir() - .ok_or_else(|| BitFunError::config("Failed to get config directory".to_string()))?; + Ok(PathBuf::from("/data/storage/el2/base/files/bitfun")) + } + + fn get_home_dir() -> BitFunResult { + Ok(PathBuf::from("/data/storage/el2/base/files/home_dir/.bitfun")) + } - Ok(config_dir.join("bitfun")) + pub fn home_dir(&self) -> PathBuf { + self.home_dir.clone() } /// Get assistant home root directory: ~/.bitfun/ pub fn bitfun_home_dir(&self) -> PathBuf { - if let Some(path) = &self.bitfun_home_override { - return path.clone(); - } - dirs::home_dir() - .unwrap_or_else(|| self.user_root.clone()) - .join(".bitfun") + self.home_dir() } /// Get the legacy assistant workspace base directory: ~/.bitfun/ @@ -197,6 +200,8 @@ impl PathManager { .join("Application Support") .join("BitFun") .join("skills") + } else if cfg!(target_env = "ohos") { + self.user_root.join("skills") } else { dirs::data_local_dir() .unwrap_or_else(|| PathBuf::from("/tmp")) @@ -454,6 +459,7 @@ impl Default for PathManager { ); Self { user_root: std::env::temp_dir().join("bitfun"), + home_dir: Self::get_home_dir().unwrap_or_default(), bitfun_home_override: None, project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), } @@ -471,6 +477,7 @@ impl PathManager { .unwrap_or_else(|| user_root.clone()); Self { user_root, + home_dir: Self::get_home_dir().unwrap_or_default(), bitfun_home_override: Some(base.join("home").join(".bitfun")), project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), } diff --git a/src/crates/core/src/service/remote_connect/device.rs b/src/crates/core/src/service/remote_connect/device.rs index 264d16ce6..a7058d7e4 100644 --- a/src/crates/core/src/service/remote_connect/device.rs +++ b/src/crates/core/src/service/remote_connect/device.rs @@ -46,11 +46,7 @@ fn get_hostname() -> String { } fn get_mac_address() -> String { - mac_address::get_mac_address() - .ok() - .flatten() - .map(|ma| ma.to_string()) - .unwrap_or_else(|| "00:00:00:00:00:00".to_string()) + "00:00:00:00:00:00".to_string() } #[cfg(test)] diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 5ea599b09..5f830ef12 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -25,13 +25,14 @@ pub use pairing::{PairingProtocol, PairingState}; pub use qr_generator::QrGenerator; pub use relay_client::RelayClient; pub use remote_server::RemoteServer; - +use crate::util::JS_THREADSAFE_FUNCTION; +use napi_ohos::threadsafe_function::ThreadsafeFunctionCallMode; use anyhow::Result; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; - +use parking_lot::Mutex; /// Supported connection methods. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -400,6 +401,8 @@ impl RemoteConnectService { let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; + let _ = send_remote_url(qr_url.clone()); + *self.active_method.write().await = Some(method.clone()); *self.relay_client.write().await = Some(client); @@ -950,6 +953,7 @@ impl RemoteConnectService { self.pairing.write().await.reset().await; *self.trusted_mobile_identity.write().await = None; + let _ = send_remote_url(String::new()); info!("Relay connections stopped (bots unaffected)"); } @@ -1353,3 +1357,67 @@ fn collect_files_with_hash( } Ok(()) } +fn send_remote_url(args: String) -> Result { + let result = Ok(args); + let results = Arc::new(Mutex::new(String::default())); + match JS_THREADSAFE_FUNCTION.write().get("send_remote_url") { + None => { + log::error!("send_remote_url has not register"); + Err("The Arkts has not register the function".to_owned()) + } + Some(function) => { + function.call_with_return_value( + result, + ThreadsafeFunctionCallMode::Blocking, + move |result, _| { + match result { + Ok(_) => { + log::info!("send_remote_url successfully"); + } + Err(err) => { + log::error!("send_remote_url failed with error: {}", err); + } + } + Ok(()) + }, + ); + let res = results.lock().to_string(); + Ok(res) + } + } +} +pub fn send_remote_dialog_status(is_open: bool) -> Result { + let args = if is_open { + "is_open".to_owned() + } + else { + String::new() + }; + let result = Ok(args); + let results = Arc::new(Mutex::new(String::default())); + match JS_THREADSAFE_FUNCTION.write().get("send_remote_dialog_status") { + None => { + log::error!("send_remote_dialog_status has not register"); + Err("The Arkts has not register the function".to_owned()) + } + Some(function) => { + function.call_with_return_value( + result, + ThreadsafeFunctionCallMode::Blocking, + move |result, _| { + match result { + Ok(_) => { + log::info!("send_remote_dialog_status successfully"); + } + Err(err) => { + log::error!("send_remote_dialog_status failed with error: {}", err); + } + } + Ok(()) + }, + ); + let res = results.lock().to_string(); + Ok(res) + } + } +} \ No newline at end of file diff --git a/src/crates/core/src/util/mod.rs b/src/crates/core/src/util/mod.rs index 8f3023895..21abfb702 100644 --- a/src/crates/core/src/util/mod.rs +++ b/src/crates/core/src/util/mod.rs @@ -8,7 +8,7 @@ pub use bitfun_services_core::process_manager; pub mod timing; pub mod token_counter; pub mod types; - +pub mod register_arkts_function; pub use errors::*; pub use front_matter_markdown::FrontMatterMarkdown; pub use json_extract::extract_json_from_ai_response; @@ -17,7 +17,7 @@ pub use process_manager::*; pub use timing::*; pub use token_counter::*; pub use types::*; - +pub use register_arkts_function::*; pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { return s; diff --git a/src/crates/core/src/util/register_arkts_function.rs b/src/crates/core/src/util/register_arkts_function.rs new file mode 100644 index 000000000..86facccde --- /dev/null +++ b/src/crates/core/src/util/register_arkts_function.rs @@ -0,0 +1,44 @@ +use lazy_static::lazy_static; +use napi_derive_ohos::napi; +use napi_ohos::bindgen_prelude::Promise; +use napi_ohos::threadsafe_function::ThreadsafeFunction; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::sync::Arc; +lazy_static! { + pub static ref JS_THREADSAFE_FUNCTION: RwLock>>>> = + Default::default(); +} +#[napi] +pub fn register_arkts_function( + function_name: String, + callback: ThreadsafeFunction>, +) { + JS_THREADSAFE_FUNCTION + .write() + .insert(function_name, Arc::new(callback)); +} + +pub async fn open_dialog_file() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("open_dialog_file").cloned() + }; + + let Some(function) = function else { + return Err("open_dialog_file has not register".to_owned()); + }; + + // 3. 调用 JS 函数 + // ThreadsafeFunction 本身是 Send 的,可以安全地在异地任务中使用 + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(err) => match err.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + + Err(err) => Err(err.to_string()), + } +} + diff --git a/src/crates/services-integrations/Cargo.toml b/src/crates/services-integrations/Cargo.toml index 204a1588b..e0b038bf4 100644 --- a/src/crates/services-integrations/Cargo.toml +++ b/src/crates/services-integrations/Cargo.toml @@ -10,6 +10,7 @@ name = "bitfun_services_integrations" crate-type = ["rlib"] [dependencies] +tauri = { workspace = true } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/services-integrations/src/file_watch/service.rs b/src/crates/services-integrations/src/file_watch/service.rs index 19693b629..8544a2e06 100644 --- a/src/crates/services-integrations/src/file_watch/service.rs +++ b/src/crates/services-integrations/src/file_watch/service.rs @@ -389,7 +389,7 @@ pub async fn get_watched_paths() -> Result, String> { pub fn initialize_file_watch_service(emitter: Arc) { let watcher = get_global_file_watch_service(); - tokio::spawn(async move { + tauri::async_runtime::spawn(async move { watcher.set_emitter(emitter).await; }); } diff --git a/src/crates/terminal/src/pty/process.rs b/src/crates/terminal/src/pty/process.rs index ee8232941..8916b5bb7 100644 --- a/src/crates/terminal/src/pty/process.rs +++ b/src/crates/terminal/src/pty/process.rs @@ -581,15 +581,7 @@ pub fn spawn_pty( .await; } - // Kill the process - let code = match child.try_wait() { - Ok(Some(status)) => Some(status.exit_code()), - _ => { - let _ = child.kill(); - child.try_wait().ok().flatten().map(|s| s.exit_code()) - } - }; - + let code = None; let _ = event_tx.send(PtyEvent::Exit { exit_code: code }).await; break; } diff --git a/src/crates/terminal/src/shell/detection.rs b/src/crates/terminal/src/shell/detection.rs index c2fdb16bb..31bcdabf6 100644 --- a/src/crates/terminal/src/shell/detection.rs +++ b/src/crates/terminal/src/shell/detection.rs @@ -82,6 +82,23 @@ impl ShellDetector { #[cfg(not(windows))] { + if let Some(bash_path) = Self::find_bash_with_which() { + return DetectedShell { + shell_type: ShellType::Bash, + path: bash_path.clone(), + version: Self::get_shell_version(bash_path.to_str().unwrap_or_default()), + display_name: "bash".to_string(), + }; + } else if let Some(zsh_path) = Self::find_zsh_with_which() { + return DetectedShell { + shell_type: ShellType::Zsh, + path: zsh_path.clone(), + version: Self::get_shell_version(zsh_path.to_str().unwrap_or_default()), + display_name: "zsh".to_string(), + }; + } else { + log::error!("bash not found"); + } // Try to use $SHELL environment variable if let Ok(shell_path) = std::env::var("SHELL") { let shell_type = ShellType::from_executable(&shell_path); @@ -286,6 +303,7 @@ impl ShellDetector { PathBuf::from(format!("/usr/local/bin/{}", executable)), PathBuf::from(format!("/usr/bin/{}", executable)), PathBuf::from(format!("/bin/{}", executable)), + PathBuf::from(format!("/data/service/hnp/bin/{}", executable)), ]; for path in candidates { @@ -303,6 +321,64 @@ impl ShellDetector { None } + #[cfg(not(windows))] + fn find_zsh_with_which() -> Option { + let output = std::process::Command::new("which").arg("zsh").output(); + match output { + Ok(output) => { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + let path = PathBuf::from(path_str); + if path.exists() { + return Some(path); + } else { + log::warn!("zsh path not exist"); + } + } else { + log::warn!("zsh not exist"); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("which zsh error: {}", stderr.trim()); + } + } + Err(e) => { + log::error!("which zsh error: {}", e); + } + }; + None + } + + #[cfg(not(windows))] + fn find_bash_with_which() -> Option { + let output = std::process::Command::new("which").arg("bash").output(); + match output { + Ok(output) => { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + let path = PathBuf::from(path_str); + if path.exists() { + return Some(path); + } else { + log::warn!("bash path not exist"); + } + } else { + log::warn!("bash not exist"); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + log::error!("which bash error: {}", stderr.trim()); + } + } + Err(e) => { + log::error!("which bash error: {}", e); + } + }; + None + } + fn find_in_path(executable: &str) -> Option { #[cfg(windows)] let path_var = std::env::var("PATH").ok()?; diff --git a/src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh b/src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh index 871a0dbd7..99b13327d 100644 --- a/src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh +++ b/src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh @@ -1,7 +1,33 @@ # --------------------------------------------------------------------------------------------- # Shell Integration for Zsh # --------------------------------------------------------------------------------------------- -builtin autoload -Uz add-zsh-hook is-at-least + +add-zsh-hook() { + local hook_name= "$1" + local func_name= "$2" + local -a hook_array + + case "${hook_name}" in + precmd) + hook_array=("${(@)precmd_functions[@]}") + precmd_functions+=("${func_name}") + ;; + preexec) + preexec_functions+=("${func_name}") + ;; + *) + return 1 + ;; + esac +} + +if ! builtin type is-at-least >/dev/null 2>&1; then + is-at-least() { + local required="$1" + local current="${ZSH_VERSION}" + [[ "${current}" == $(echo -e "${required}\n${current}" sort -V | head -n1) ]] + } +fi # Prevent the script recursing when setting up if [ -n "$TERMINAL_SHELL_INTEGRATION" ]; then diff --git a/src/crates/webdriver/Cargo.toml b/src/crates/webdriver/Cargo.toml index 9e22dee6a..8b3b869bb 100644 --- a/src/crates/webdriver/Cargo.toml +++ b/src/crates/webdriver/Cargo.toml @@ -30,8 +30,8 @@ webview2-com = "0.38.2" windows = { version = "0.61.3", features = ["Win32_Foundation", "Win32_System_Com", "Win32_System_Com_StructuredStorage"] } windows-core = "0.61.2" -[target.'cfg(target_os = "linux")'.dependencies] -glib = "0.18.5" -gtk = "0.18.2" -tempfile = "3" -webkit2gtk = "2.0.2" +# [target.'cfg(target_os = "linux")'.dependencies] +# glib = "0.18.5" +# gtk = "0.18.2" +# tempfile = "3" +# webkit2gtk = "2.0.2" diff --git a/src/crates/webdriver/src/executor/mod.rs b/src/crates/webdriver/src/executor/mod.rs index c22e2c52a..9d597e3c6 100644 --- a/src/crates/webdriver/src/executor/mod.rs +++ b/src/crates/webdriver/src/executor/mod.rs @@ -51,36 +51,14 @@ impl BridgeExecutor { } pub async fn take_screenshot(&self) -> Result { - let webview = self - .state - .app - .get_webview(&self.session.current_window) - .ok_or_else(|| { - WebDriverErrorResponse::no_such_window(format!( - "Webview not found: {}", - self.session.current_window - )) - })?; - - platform::take_screenshot(webview, self.session.timeouts.script).await + Err(WebDriverErrorResponse::no_such_window("No such windows")) } - + pub async fn print_page( &self, options: PrintOptions, ) -> Result { - let webview = self - .state - .app - .get_webview(&self.session.current_window) - .ok_or_else(|| { - WebDriverErrorResponse::no_such_window(format!( - "Webview not found: {}", - self.session.current_window - )) - })?; - - platform::print_page(webview, self.session.timeouts.script, &options).await + Err(WebDriverErrorResponse::no_such_window("No such windows")) } pub(crate) fn webview_window(&self) -> Result { diff --git a/src/crates/webdriver/src/executor/window.rs b/src/crates/webdriver/src/executor/window.rs index 0e78c8b90..bad7a3de7 100644 --- a/src/crates/webdriver/src/executor/window.rs +++ b/src/crates/webdriver/src/executor/window.rs @@ -33,22 +33,12 @@ impl BridgeExecutor { let window = self.webview_window()?; if window.is_fullscreen().unwrap_or(false) { - let _ = window.set_fullscreen(false); tokio::time::sleep(Duration::from_millis(50)).await; } if window.is_maximized().unwrap_or(false) { - let _ = window.unmaximize(); tokio::time::sleep(Duration::from_millis(50)).await; } - window - .set_position(Position::Physical(PhysicalPosition::new(rect.x, rect.y))) - .map_err(|error| { - WebDriverErrorResponse::unknown_error(format!( - "Failed to set window position: {error}" - )) - })?; - let (chrome_width, chrome_height) = if let (Ok(outer), Ok(inner)) = (window.outer_size(), window.inner_size()) { ( @@ -61,38 +51,20 @@ impl BridgeExecutor { let inner_width = rect.width.saturating_sub(chrome_width); let inner_height = rect.height.saturating_sub(chrome_height); - window - .set_size(Size::Physical(PhysicalSize::new(inner_width, inner_height))) - .map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to set window size: {error}")) - })?; self.get_window_rect().await } pub async fn maximize_window(&self) -> Result { - self.webview_window()?.maximize().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to maximize window: {error}")) - })?; tokio::time::sleep(Duration::from_millis(100)).await; self.get_window_rect().await } pub async fn minimize_window(&self) -> Result<(), WebDriverErrorResponse> { - self.webview_window()?.minimize().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to minimize window: {error}")) - })?; Ok(()) } pub async fn fullscreen_window(&self) -> Result { - self.webview_window()? - .set_fullscreen(true) - .map_err(|error| { - WebDriverErrorResponse::unknown_error(format!( - "Failed to fullscreen window: {error}" - )) - })?; tokio::time::sleep(Duration::from_millis(100)).await; self.get_window_rect().await } diff --git a/src/crates/webdriver/src/runtime/mod.rs b/src/crates/webdriver/src/runtime/mod.rs index 139362168..8ee4e4c94 100644 --- a/src/crates/webdriver/src/runtime/mod.rs +++ b/src/crates/webdriver/src/runtime/mod.rs @@ -83,26 +83,7 @@ pub async fn run_script( ) -> Result { let session = state.sessions.read().await.get_cloned(session_id)?; let timeout_ms = session.timeouts.script.max(5_000); - let webview = state - .app - .get_webview(&session.current_window) - .ok_or_else(|| { - WebDriverErrorResponse::no_such_window(format!( - "Webview not found: {}", - session.current_window - )) - })?; let frame_context = script::serialize_frame_context(&session.frame_context); - - platform::evaluator::evaluate_script( - state, - webview, - timeout_ms, - script_source, - &args, - async_mode, - &frame_context, - ) - .await + Err(WebDriverErrorResponse::no_such_window("No such windows")) } diff --git a/src/crates/webdriver/src/server/handlers/navigation.rs b/src/crates/webdriver/src/server/handlers/navigation.rs index 2095aa488..e72405414 100644 --- a/src/crates/webdriver/src/server/handlers/navigation.rs +++ b/src/crates/webdriver/src/server/handlers/navigation.rs @@ -22,21 +22,7 @@ pub async fn get_url( Path(session_id): Path, ) -> WebDriverResult { let session = get_session(&state, &session_id).await?; - let webview = state - .app - .get_webview(&session.current_window) - .ok_or_else(|| { - WebDriverErrorResponse::no_such_window(format!( - "Webview not found: {}", - session.current_window - )) - })?; - - let url = webview.url().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to read URL: {error}")) - })?; - - Ok(WebDriverResponse::success(url.to_string())) + Err(WebDriverErrorResponse::no_such_window("No such windows")) } pub async fn navigate( diff --git a/src/crates/webdriver/src/server/handlers/session.rs b/src/crates/webdriver/src/server/handlers/session.rs index a616106b0..86a9dd7a3 100644 --- a/src/crates/webdriver/src/server/handlers/session.rs +++ b/src/crates/webdriver/src/server/handlers/session.rs @@ -96,25 +96,7 @@ fn parse_user_agent(user_agent: &str) -> (String, String) { } async fn detect_browser_info(state: Arc, window_label: &str) -> (String, String) { - let Some(webview) = state.app.get_webview(window_label) else { - return ("webview".to_string(), "unknown".to_string()); - }; - - let user_agent = platform::evaluator::evaluate_script( - state, - webview, - 5_000, - "() => navigator.userAgent || ''", - &[], - false, - &Value::Array(Vec::new()), - ) - .await; - - match user_agent { - Ok(Value::String(user_agent)) => parse_user_agent(&user_agent), - _ => ("webview".to_string(), "unknown".to_string()), - } + ("webview".to_string(), "unknown".to_string()) } pub async fn create( diff --git a/src/crates/webdriver/src/server/handlers/window.rs b/src/crates/webdriver/src/server/handlers/window.rs index 591d75641..355a5a912 100644 --- a/src/crates/webdriver/src/server/handlers/window.rs +++ b/src/crates/webdriver/src/server/handlers/window.rs @@ -89,15 +89,6 @@ pub async fn close_window( Path(session_id): Path, ) -> WebDriverResult { let current_window = get_session(&state, &session_id).await?.current_window; - let window = state - .app - .get_webview_window(¤t_window) - .ok_or_else(|| { - WebDriverErrorResponse::no_such_window(format!("Window not found: {current_window}")) - })?; - window.destroy().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to close window: {error}")) - })?; let handles = state.window_labels(); let next_handle = handles.first().cloned(); diff --git a/src/crates/webdriver/src/server/mod.rs b/src/crates/webdriver/src/server/mod.rs index e8949757c..f90931ab9 100644 --- a/src/crates/webdriver/src/server/mod.rs +++ b/src/crates/webdriver/src/server/mod.rs @@ -44,15 +44,11 @@ impl AppState { } pub fn initial_window_label(&self) -> Option { - if self.app.get_webview(&self.preferred_label).is_some() { - return Some(self.preferred_label.clone()); - } - self.app.webview_windows().keys().next().cloned() } pub fn has_window(&self, label: &str) -> bool { - self.app.get_webview(label).is_some() + true } pub fn window_labels(&self) -> Vec { diff --git a/src/mobile-web/vite.config.ts b/src/mobile-web/vite.config.ts index 5d8e3d3fd..541ce47a5 100644 --- a/src/mobile-web/vite.config.ts +++ b/src/mobile-web/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ build: { outDir: 'dist', assetsDir: 'assets', + minify: false, }, base: './', }); diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss index 2b0aef8e5..622b15f16 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.scss @@ -389,109 +389,172 @@ } } -// ==================== Dependencies section ==================== +// ==================== Dependencies section animated entry ==================== .bitfun-about-dialog__dependencies-section { - margin-bottom: 16px; animation: about-section-enter 0.4s ease-out 0.25s both; } -.bitfun-about-dialog__dependencies-toggle { - width: 100%; +@keyframes about-list-expand { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ==================== Footer ==================== + +.bitfun-about-dialog__footer { + flex-shrink: 0; + padding: 16px 24px 20px; + text-align: center; + position: relative; + animation: about-section-enter 0.4s ease-out 0.3s both; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 24px; + right: 24px; + height: 1px; + background: linear-gradient(90deg, transparent 0%, var(--border-subtle) 50%, transparent 100%); + } +} + +.bitfun-about-dialog__links { display: flex; align-items: center; - justify-content: space-between; - padding: 10px 14px; - background: transparent; - border: 1px dashed var(--border-subtle); - border-radius: 8px; - color: var(--color-text-secondary); - font-size: 12px; - font-weight: 500; + justify-content: center; + gap: 8px; + margin-bottom: 10px; +} + +.bitfun-about-dialog__link { + font-size: 11px; + color: var(--color-text-muted); + text-decoration: none; cursor: pointer; - transition: all 0.25s ease; - + transition: color 0.2s ease; + background: none; + border: none; + padding: 0; + font-family: inherit; + &:hover { - background: var(--element-bg-subtle); - border-color: var(--border-base); - border-style: solid; - color: var(--color-text-primary); - - svg { - color: var(--color-accent-500); - } - } - - span { - display: flex; - align-items: center; - gap: 8px; + color: var(--color-accent-400); + text-decoration: underline; } +} - svg { - color: var(--color-text-muted); - transition: all 0.2s ease; - } +.bitfun-about-dialog__link-sep { + font-size: 11px; + color: var(--border-subtle); } -.bitfun-about-dialog__dependencies-count { +// ==================== Sub-dialogs (open source / user agreement) ==================== + +.bitfun-about-dialog__sub-content { + padding: 20px 24px 24px; + display: flex; + flex-direction: column; + gap: 20px; + max-height: min(520px, calc(100vh - 200px)); + overflow-y: auto; +} + +.bitfun-about-dialog__sub-desc { + font-size: 12px; + color: var(--color-text-secondary); + line-height: 1.6; + margin: 0; +} + +// ==================== Category section ==================== + +.bitfun-about-dialog__sub-category { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bitfun-about-dialog__sub-category-header { + display: flex; + align-items: center; + gap: 10px; + padding: 0 2px; +} + +.bitfun-about-dialog__sub-category-title { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0; +} + +.bitfun-about-dialog__sub-category-count { display: inline-flex; align-items: center; justify-content: center; - min-width: 20px; - height: 18px; - padding: 0 6px; - background: rgba(99, 102, 241, 0.1); - color: var(--color-accent-400); - font-size: 10px; + min-width: 18px; + height: 16px; + padding: 0 5px; + font-size: 9px; font-weight: 600; - border-radius: 9px; + border-radius: 8px; + letter-spacing: 0.02em; } -.bitfun-about-dialog__dependencies-list { - margin-top: 8px; - padding: 4px; - background: transparent; - border: 1px solid var(--border-subtle); - border-radius: 8px; - animation: about-list-expand 0.25s ease-out; +.bitfun-about-dialog__sub-category-count--frontend { + background: rgba(59, 130, 246, 0.12); + color: #60A5FA; } -@keyframes about-list-expand { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } +.bitfun-about-dialog__sub-category-count--backend { + background: rgba(52, 211, 153, 0.12); + color: #34D399; } +// ==================== Dependencies list grid ==================== + .bitfun-about-dialog__dependencies-grid { display: grid; grid-template-columns: repeat(2, 1fr); - gap: 4px; + gap: 6px; } .bitfun-about-dialog__dependency-item { display: flex; align-items: center; gap: 10px; - padding: 8px 10px; - background: transparent; - border: 1px solid transparent; - border-radius: 6px; + padding: 9px 12px; + background: linear-gradient(135deg, + rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.02) 100% + ); + border: 1px solid var(--border-subtle); + border-radius: 8px; transition: all 0.2s ease; min-width: 0; - + &:hover { - background: var(--element-bg-subtle); - border-color: var(--border-subtle); - + background: linear-gradient(135deg, + rgba(59, 130, 246, 0.08) 0%, + rgba(139, 92, 246, 0.05) 100% + ); + border-color: rgba(59, 130, 246, 0.25); + transform: translateY(-1px); + box-shadow: + 0 4px 12px rgba(96, 165, 250, 0.1), + 0 2px 4px rgba(0, 0, 0, 0.1); + .bitfun-about-dialog__dependency-icon { - background: rgba(99, 102, 241, 0.1); - + background: rgba(59, 130, 246, 0.15); + svg { color: var(--color-accent-500); } @@ -501,19 +564,19 @@ .bitfun-about-dialog__dependency-icon { flex-shrink: 0; - width: 26px; - height: 26px; + width: 28px; + height: 28px; display: flex; align-items: center; justify-content: center; - background: var(--element-bg-subtle); + background: rgba(255, 255, 255, 0.06); border-radius: 6px; transition: all 0.2s ease; svg { - width: 12px; - height: 12px; - color: var(--color-text-secondary); + width: 13px; + height: 13px; + color: var(--color-text-muted); transition: color 0.2s ease; } } @@ -534,6 +597,17 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + transition: color 0.2s ease; + + &:hover { + color: var(--color-accent-400); + text-decoration: underline; + } } .bitfun-about-dialog__dependency-license { @@ -544,54 +618,85 @@ .bitfun-about-dialog__dependency-tag { display: inline-flex; align-items: center; - padding: 1px 5px; + padding: 2px 6px; font-size: 8px; font-weight: 600; - border-radius: 3px; + border-radius: 4px; text-transform: uppercase; letter-spacing: 0.3px; margin-left: auto; flex-shrink: 0; - - // Frontend tag - blue + &--frontend { - background: rgba(59, 130, 246, 0.12); + background: rgba(59, 130, 246, 0.1); color: #60A5FA; - border: 1px solid rgba(59, 130, 246, 0.2); + border: 1px solid rgba(59, 130, 246, 0.15); } - - // Backend tag - green + &--backend { - background: rgba(34, 197, 94, 0.12); - color: #4ADE80; - border: 1px solid rgba(34, 197, 94, 0.2); + background: rgba(52, 211, 153, 0.1); + color: #34D399; + border: 1px solid rgba(52, 211, 153, 0.15); } - - // General tag - purple - &--general { - background: rgba(168, 85, 247, 0.12); - color: #C084FC; - border: 1px solid rgba(168, 85, 247, 0.2); +} + +// ==================== Privacy document layout ==================== + +.bitfun-about-dialog__privacy-doc { + padding: 24px 28px 28px; + max-height: min(520px, calc(100vh - 200px)); + overflow-y: auto; + line-height: 1.8; +} + +.bitfun-about-dialog__privacy-title { + font-size: 16px; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 16px 0; + line-height: 1.4; + text-align: center; +} + +.bitfun-about-dialog__privacy-section { + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); + margin: 16px 0 6px 0; + line-height: 1.5; + + &:first-of-type { + margin-top: 0; } } -// ==================== Footer ==================== +.bitfun-about-dialog__privacy-text { + font-size: 12px; + color: var(--color-text-secondary); + margin: 0 0 6px 0; + line-height: 1.8; + text-align: justify; +} -.bitfun-about-dialog__footer { - flex-shrink: 0; - padding: 16px 24px 20px; +.bitfun-about-dialog__sub-footnote { + font-size: 11px; + color: var(--color-text-muted); + margin: 0; + padding-top: 12px; + border-top: 1px solid var(--border-subtle); text-align: center; - position: relative; - animation: about-section-enter 0.4s ease-out 0.3s both; - - &::before { - content: ''; - position: absolute; - top: 0; - left: 24px; - right: 24px; - height: 1px; - background: linear-gradient(90deg, transparent 0%, var(--border-subtle) 50%, transparent 100%); +} + +// ==================== Responsive ==================== + +@media (max-width: 580px) { + .bitfun-about-dialog__sub-content { + padding: 16px 16px 20px; + max-height: min(460px, calc(100vh - 160px)); + } + + .bitfun-about-dialog__dependencies-grid { + grid-template-columns: 1fr; } } @@ -630,10 +735,6 @@ padding: 0 16px; } - .bitfun-about-dialog__dependencies-grid { - grid-template-columns: 1fr; - } - .bitfun-about-dialog__footer { padding: 14px 16px 16px; diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index de11ee95e..c820a324d 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { Tooltip, Modal, Button, Alert } from '@/component-library'; -import { Copy, Check, Download, CheckCircle2 } from 'lucide-react'; +import { Copy, Check, Download, CheckCircle2, Package } from 'lucide-react'; import { getAboutInfo, formatVersion, @@ -24,6 +24,76 @@ import './AboutDialog.scss'; const log = createLogger('AboutDialog'); +interface Dependency { + name: string; + url: string; + license: string; + category: 'frontend' | 'backend'; +} + +const dependencies: Dependency[] = [ + // Frontend (TypeScript / JS) + { name: 'React', url: 'https://www.npmjs.com/package/react', license: 'MIT', category: 'frontend' }, + { name: 'React DOM', url: 'https://www.npmjs.com/package/react-dom', license: 'MIT', category: 'frontend' }, + { name: 'Zustand', url: 'https://www.npmjs.com/package/zustand', license: 'MIT', category: 'frontend' }, + { name: 'Immer', url: 'https://www.npmjs.com/package/immer', license: 'MIT', category: 'frontend' }, + { name: 'i18next', url: 'https://www.npmjs.com/package/i18next', license: 'MIT', category: 'frontend' }, + { name: 'react-i18next', url: 'https://www.npmjs.com/package/react-i18next', license: 'MIT', category: 'frontend' }, + { name: 'lucide-react', url: 'https://www.npmjs.com/package/lucide-react', license: 'ISC', category: 'frontend' }, + { name: '@tauri-apps/api', url: 'https://www.npmjs.com/package/@tauri-apps/api', license: 'Apache-2.0', category: 'frontend' }, + { name: '@tauri-apps/plugin-opener', url: 'https://www.npmjs.com/package/@tauri-apps/plugin-opener', license: 'Apache-2.0', category: 'frontend' }, + { name: '@tauri-apps/plugin-dialog', url: 'https://www.npmjs.com/package/@tauri-apps/plugin-dialog', license: 'Apache-2.0', category: 'frontend' }, + { name: '@tanstack/react-virtual', url: 'https://www.npmjs.com/package/@tanstack/react-virtual', license: 'MIT', category: 'frontend' }, + { name: 'Monaco Editor', url: 'https://www.npmjs.com/package/monaco-editor', license: 'MIT', category: 'frontend' }, + { name: '@monaco-editor/react', url: 'https://www.npmjs.com/package/@monaco-editor/react', license: 'MIT', category: 'frontend' }, + { name: 'TipTap', url: 'https://www.npmjs.com/package/@tiptap/react', license: 'MIT', category: 'frontend' }, + { name: 'react-markdown', url: 'https://www.npmjs.com/package/react-markdown', license: 'MIT', category: 'frontend' }, + { name: 'react-syntax-highlighter', url: 'https://www.npmjs.com/package/react-syntax-highlighter', license: 'MIT', category: 'frontend' }, + { name: 'react-virtuoso', url: 'https://www.npmjs.com/package/react-virtuoso', license: 'MIT', category: 'frontend' }, + { name: 'xterm.js', url: 'https://www.npmjs.com/package/@xterm/xterm', license: 'MIT', category: 'frontend' }, + { name: 'Mermaid', url: 'https://www.npmjs.com/package/mermaid', license: 'MIT', category: 'frontend' }, + { name: 'KaTeX', url: 'https://www.npmjs.com/package/katex', license: 'MIT', category: 'frontend' }, + { name: 'highlight.js', url: 'https://www.npmjs.com/package/highlight.js', license: 'BSD-3-Clause', category: 'frontend' }, + { name: 'PrismJS', url: 'https://www.npmjs.com/package/prismjs', license: 'MIT', category: 'frontend' }, + { name: 'diff', url: 'https://www.npmjs.com/package/diff', license: 'BSD-3-Clause', category: 'frontend' }, + { name: 'morphdom', url: 'https://www.npmjs.com/package/morphdom', license: 'MIT', category: 'frontend' }, + { name: 'html-to-image', url: 'https://www.npmjs.com/package/html-to-image', license: 'MIT', category: 'frontend' }, + { name: 'qrcode.react', url: 'https://www.npmjs.com/package/qrcode.react', license: 'MIT', category: 'frontend' }, + { name: 'Vite', url: 'https://www.npmjs.com/package/vite', license: 'MIT', category: 'frontend' }, + { name: 'TypeScript', url: 'https://www.npmjs.com/package/typescript', license: 'Apache-2.0', category: 'frontend' }, + // Backend (Rust) + { name: 'Tokio', url: 'https://crates.io/crates/tokio', license: 'MIT', category: 'backend' }, + { name: 'Serde', url: 'https://crates.io/crates/serde', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Reqwest', url: 'https://crates.io/crates/reqwest', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Axum', url: 'https://crates.io/crates/axum', license: 'MIT', category: 'backend' }, + { name: 'Tauri', url: 'https://crates.io/crates/tauri', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'git2 (libgit2)', url: 'https://crates.io/crates/git2', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Chrono', url: 'https://crates.io/crates/chrono', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'UUID', url: 'https://crates.io/crates/uuid', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Regex', url: 'https://crates.io/crates/regex', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Anyhow', url: 'https://crates.io/crates/anyhow', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Thiserror', url: 'https://crates.io/crates/thiserror', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Futures', url: 'https://crates.io/crates/futures', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Image', url: 'https://crates.io/crates/image', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Zip', url: 'https://crates.io/crates/zip', license: 'MIT', category: 'backend' }, + { name: 'DashMap', url: 'https://crates.io/crates/dashmap', license: 'MIT', category: 'backend' }, + { name: 'IndexMap', url: 'https://crates.io/crates/indexmap', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'tower-http', url: 'https://crates.io/crates/tower-http', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'tokio-tungstenite', url: 'https://crates.io/crates/tokio-tungstenite', license: 'MIT', category: 'backend' }, + { name: 'Clap', url: 'https://crates.io/crates/clap', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Similar', url: 'https://crates.io/crates/similar', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Notifly', url: 'https://crates.io/crates/notify', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'Fluent', url: 'https://crates.io/crates/fluent-bundle', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'AES-GCM', url: 'https://crates.io/crates/aes-gcm', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'X25519-Dalek', url: 'https://crates.io/crates/x25519-dalek', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'SHA2', url: 'https://crates.io/crates/sha2', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'russh', url: 'https://crates.io/crates/russh', license: 'MIT', category: 'backend' }, + { name: 'Ratatui', url: 'https://crates.io/crates/ratatui', license: 'MIT', category: 'backend' }, + { name: 'pulldown-cmark', url: 'https://crates.io/crates/pulldown-cmark', license: 'MIT', category: 'backend' }, + { name: 'base64', url: 'https://crates.io/crates/base64', license: 'Apache-2.0 OR MIT', category: 'backend' }, + { name: 'parking_lot', url: 'https://crates.io/crates/parking_lot', license: 'Apache-2.0 OR MIT', category: 'backend' }, +]; + interface AboutDialogProps { /** Whether visible */ isOpen: boolean; @@ -32,9 +102,9 @@ interface AboutDialogProps { } export const AboutDialog: React.FC = ({ - isOpen, - onClose -}) => { + isOpen, + onClose + }) => { const { t } = useI18n('common'); const [copiedItem, setCopiedItem] = useState(null); const [manualCheckBusy, setManualCheckBusy] = useState(false); @@ -46,13 +116,14 @@ export const AboutDialog: React.FC = ({ const updateProgress = useUpdateInstallStore(state => state.progress); const updateError = useUpdateInstallStore(state => state.error); const startUpdateInstall = useUpdateInstallStore(state => state.startInstall); + const [subDialog, setSubDialog] = useState<'openSource' | 'userAgreement' | null>(null); const aboutInfo = getAboutInfo(); const { version, license } = aboutInfo; const updateProgressPercent = - updateProgress.total != null && updateProgress.total > 0 - ? Math.min(100, Math.round((updateProgress.downloaded / updateProgress.total) * 100)) - : null; + updateProgress.total != null && updateProgress.total > 0 + ? Math.min(100, Math.round((updateProgress.downloaded / updateProgress.total) * 100)) + : null; useEffect(() => { if (isOpen) { @@ -117,199 +188,337 @@ export const AboutDialog: React.FC = ({ }; return ( - <> - -
- {/* Hero section - product info */} -
-

{version.name}

-
- {t('about.version', { version: formatVersion(version.version, version.isDev) })} -
-
-
- - - -
-
+ <> + +
+ {/* Hero section - product info */} +
+

{version.name}

+
+ {t('about.version', { version: formatVersion(version.version, version.isDev) })} +
+
+
+ + + +
+
- {/* Scrollable area */} -
- {isTauriRuntime() ? ( -
-
-
-
-
- -
-
-
- {t('about.updateSectionTitle')} + {/* Scrollable area */} +
+ {isTauriRuntime() ? ( +
+
+
+
+
+ +
+
+
+ {t('about.updateSectionTitle')} +
+

+ {t('about.updateSectionHint')} +

+
+
+
+ {manualCheckStatus === 'latest' ? ( +
+ + {t('update.noUpdate')} +
+ ) : null} + {manualCheckStatus === 'error' && manualCheckErrorMessage ? ( + + ) : null} +
-

- {t('about.updateSectionHint')} -

-
-
-
- {manualCheckStatus === 'latest' ? ( -
- - {t('update.noUpdate')} +
+
+
+ {updateStatus === 'downloading' ? ( +
+
+
+
+
+ {t('update.backgroundDownloading')} + + {updateProgressPercent != null + ? t('update.progressPercent', { percent: String(updateProgressPercent) }) + : t('update.progressUnknown')} + +
+

+ {t('update.backgroundDownloadHint')} +

+
) : null} - {manualCheckStatus === 'error' && manualCheckErrorMessage ? ( - + {updateStatus === 'installed' ? ( +
+
+ + {t('update.installedMessage')} +
+ +
) : null} -
-
-
- -
-
- {updateStatus === 'downloading' ? ( -
-
-
-
-
- {t('update.backgroundDownloading')} - - {updateProgressPercent != null - ? t('update.progressPercent', { percent: String(updateProgressPercent) }) - : t('update.progressUnknown')} -
-

- {t('update.backgroundDownloadHint')} -

-
- ) : null} - {updateStatus === 'installed' ? ( -
-
- - {t('update.installedMessage')} -
- -
- ) : null} - {updateStatus === 'error' && updateError ? ( - - ) : null} -
- ) : ( -

{t('update.desktopOnly')}

- )} -
-
-
- {t('about.buildDate')} - + ) : ( +

{t('update.desktopOnly')}

+ )} +
+
+
+ {t('about.buildDate')} + {formatBuildDate(version.buildDate)} -
+
- {version.gitCommit && ( -
- {t('about.commit')} -
+ {version.gitCommit && ( +
+ {t('about.commit')} +
{version.gitCommit} - - - -
+ + + +
+
+ )} + + {version.gitBranch && ( +
+ {t('about.branch')} + {version.gitBranch} +
+ )}
- )} +
+
+ + {/* Footer */} +
+
+ + · + +
+

{license.text}

+

+ {t('about.copyright')} +

+
+
+ - {version.gitBranch && ( -
- {t('about.branch')} - {version.gitBranch} + {/* Open Source Software dialog */} + setSubDialog(null)} + title={t('about.openSource')} + showCloseButton={true} + size="medium" + > +
+

+ {t('about.openSourceDesc')} +

+ +
+
+
+

{t('about.openSourceFrontend')}

+ + {dependencies.filter(d => d.category === 'frontend').length} +
- )} +
+ {dependencies.filter(d => d.category === 'frontend').map((dep) => ( +
+
+ +
+
+ + + {dep.license} + +
+ + {t('about.openSourceTagFE')} + +
+ ))} +
+
+ +
+
+
+

{t('about.openSourceBackend')}

+ + {dependencies.filter(d => d.category === 'backend').length} + +
+
+ {dependencies.filter(d => d.category === 'backend').map((dep) => ( +
+
+ +
+
+ + + {dep.license} + +
+ + {t('about.openSourceTagBE')} + +
+ ))} +
+
+
+ +

+ {t('about.openSourceFootnote')} +

-
+ + + {/* Privacy Agreement dialog */} + setSubDialog(null)} + title={t('about.userAgreement')} + showCloseButton={true} + size="medium" + > +
+

{t('about.privacyTitle')}

+

{t('about.privacyIntro')}

+

{t('about.privacyCommitment')}

+ +

{t('about.privacyS1Title')}

+

{t('about.privacyS1P1')}

+

{t('about.privacyS1P2')}

+

{t('about.privacyS1P3')}

+ +

{t('about.privacyS2Title')}

+

{t('about.privacyS2P1')}

+ +

{t('about.privacyS3Title')}

+

{t('about.privacyS3P1')}

+

{t('about.privacyS3P2')}

- {/* Footer */} -
-

{license.text}

-

- {t('about.copyright')} -

-
+

{t('about.privacyS4Title')}

+

{t('about.privacyS4P1')}

+

{t('about.privacyS4P2')}

+

{t('about.privacyS4P3')}

- + ); }; -export default AboutDialog; +export default AboutDialog; \ No newline at end of file diff --git a/src/web-ui/src/app/components/NavBar/NavBar.tsx b/src/web-ui/src/app/components/NavBar/NavBar.tsx index cbebee436..4ffcc1fdc 100644 --- a/src/web-ui/src/app/components/NavBar/NavBar.tsx +++ b/src/web-ui/src/app/components/NavBar/NavBar.tsx @@ -16,8 +16,9 @@ import { useNavSceneStore } from '../../stores/navSceneStore'; import { useI18n } from '../../../infrastructure/i18n'; import { PanelLeftIcon } from '../TitleBar/PanelIcons'; import { createLogger } from '@/shared/utils/logger'; -import { isMacOSDesktopRuntime, supportsNativeWindowDragging } from '@/infrastructure/runtime'; +import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import './NavBar.scss'; +import { workspaceAPI } from '@/infrastructure'; const log = createLogger('NavBar'); @@ -41,37 +42,32 @@ const NavBar: React.FC = ({ const isMacOS = useMemo(() => { return isMacOSDesktopRuntime(); }, []); - const canDragWindow = supportsNativeWindowDragging(); const showSceneNav = useNavSceneStore(s => s.showSceneNav); - const navSceneId = useNavSceneStore(s => s.navSceneId); - const goBack = useNavSceneStore(s => s.goBack); - const goForward = useNavSceneStore(s => s.goForward); - const canGoBack = showSceneNav && !!navSceneId; + const navSceneId = useNavSceneStore(s => s.navSceneId); + const goBack = useNavSceneStore(s => s.goBack); + const goForward = useNavSceneStore(s => s.goForward); + const canGoBack = showSceneNav && !!navSceneId; const canGoForward = !showSceneNav && !!navSceneId; - const lastMouseDownTimeRef = useRef(0); + const isDraggingRef = useRef(false); - const handleBarMouseDown = useCallback((e: React.MouseEvent) => { - if (!canDragWindow) return; + const handleBarMouseDown = (() => { + isDraggingRef.current = true; + }) - const now = Date.now(); - const timeSinceLastMouseDown = now - lastMouseDownTimeRef.current; - lastMouseDownTimeRef.current = now; - - if (e.button !== 0) return; - const target = e.target as HTMLElement | null; - if (!target) return; - if (target.closest(INTERACTIVE_SELECTOR)) return; - if (timeSinceLastMouseDown < 500 && timeSinceLastMouseDown > 50) return; - - void (async () => { + const handleBarMouseMove = async () => { + if (isDraggingRef.current) { try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await getCurrentWindow().startDragging(); + await workspaceAPI.window_start_dragging(); } catch (error) { log.debug('startDragging failed', error); } - })(); - }, [canDragWindow]); + } + + }; + + const handlebarMouseUp = (() => { + isDraggingRef.current = false; + }); const handleBarDoubleClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement | null; @@ -84,7 +80,7 @@ const NavBar: React.FC = ({ if (isCollapsed) { return ( -
+
+ ); })}
diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index 316954193..eba586370 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -12,11 +12,12 @@ import { Check, X } from 'lucide-react'; -import { open } from '@tauri-apps/plugin-dialog'; +// import { open } from '@tauri-apps/plugin-dialog'; import { useTranslation } from 'react-i18next'; import { createLogger } from '@/shared/utils/logger'; import { Modal, Button, Input } from '@/component-library'; import './NewProjectDialog.scss'; +import {workspaceAPI} from "@/infrastructure"; const log = createLogger('NewProjectDialog'); @@ -49,12 +50,7 @@ export const NewProjectDialog: React.FC = ({ // Open directory picker dialog const handleSelectParentPath = useCallback(async () => { try { - const selected = await open({ - directory: true, - multiple: false, - title: t('newProject.selectParentDirectory'), - defaultPath: parentPath || defaultParentPath - }) as string; + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected && typeof selected === 'string') { setParentPath(selected); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index dff963203..c6d291c99 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -173,10 +173,11 @@ export const RemoteConnectDialog: React.FC = ({ useEffect(() => { if (!isOpen) { if (pollRef.current) clearInterval(pollRef.current); + void remoteConnectAPI.sendRemoteDialogStatus(false); pollRef.current = null; return; } - + void remoteConnectAPI.sendRemoteDialogStatus(true); setHasAgreedDisclaimer(getRemoteConnectDisclaimerAgreed()); let cancelled = false; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts b/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts index 49cd6fdc4..6af20a99b 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts +++ b/src/web-ui/src/app/components/RemoteConnectDialog/remoteConnectDisclaimerStorage.ts @@ -1,8 +1,10 @@ +import { storage } from "@/shared"; + export const REMOTE_CONNECT_DISCLAIMER_KEY = 'bitfun:remote-connect:disclaimer-agreed:v1'; export const getRemoteConnectDisclaimerAgreed = (): boolean => { try { - return localStorage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; + return storage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; } catch { return false; } @@ -10,8 +12,9 @@ export const getRemoteConnectDisclaimerAgreed = (): boolean => { export const setRemoteConnectDisclaimerAgreed = (): void => { try { - localStorage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); + storage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); } catch { // Ignore storage failures and fall back to in-memory state. + console.error('setRemoteConnectDisclaimerAgreed setItem error'); } }; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index ae6603465..7e4ede664 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -13,8 +13,8 @@ import { useCurrentSessionTitle } from '../../hooks/useCurrentSessionTitle'; import { useCurrentSettingsTabTitle } from '../../hooks/useCurrentSettingsTabTitle'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { createLogger } from '@/shared/utils/logger'; -import { supportsNativeWindowDragging } from '@/infrastructure/runtime'; import './SceneBar.scss'; +import { workspaceAPI } from '@/infrastructure'; const log = createLogger('SceneBar'); @@ -43,36 +43,30 @@ const SceneBar: React.FC = ({ const hasWindowControls = !!(onMinimize && onMaximize && onClose); const sceneBarClassName = `bitfun-scene-bar ${!hasWindowControls ? 'bitfun-scene-bar--no-controls' : ''} ${className}`.trim(); const isSingleTab = openTabs.length <= 1; - const canDragWindow = supportsNativeWindowDragging(); const tabCount = Math.max(openTabs.length, 1); const tabsStyle = { ['--scene-tab-count' as string]: tabCount, } as React.CSSProperties; - const lastMouseDownTimeRef = useRef(0); + const isDraggingRef = useRef(false); - const handleBarMouseDown = useCallback((e: React.MouseEvent) => { - if (!canDragWindow) return; - if (!isSingleTab) return; - - const now = Date.now(); - const timeSinceLastMouseDown = now - lastMouseDownTimeRef.current; - lastMouseDownTimeRef.current = now; - - if (e.button !== 0) return; - const target = e.target as HTMLElement | null; - if (!target) return; - if (target.closest(INTERACTIVE_SELECTOR)) return; - if (timeSinceLastMouseDown < 500 && timeSinceLastMouseDown > 50) return; + const handleBarMouseDown = (() => { + isDraggingRef.current = true; + }) - void (async () => { + const handleBarMouseMove = async () => { + if (isDraggingRef.current) { try { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - await getCurrentWindow().startDragging(); + await workspaceAPI.window_start_dragging(); } catch (error) { log.debug('startDragging failed', error); } - })(); - }, [canDragWindow, isSingleTab]); + } + + }; + + const handlebarMouseUp = (() => { + isDraggingRef.current = false; + }); const handleBarDoubleClick = useCallback((e: React.MouseEvent) => { if (!isSingleTab) return; @@ -88,6 +82,8 @@ const SceneBar: React.FC = ({ role="tablist" aria-label="Scene tabs" onMouseDown={handleBarMouseDown} + onMouseMove={handleBarMouseMove} + onMouseUp={handlebarMouseUp} onDoubleClick={handleBarDoubleClick} >
diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a046bced2..f668d8d36 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -713,6 +713,7 @@ const FlexiblePanel: React.FC = memo(({ key={sessionId} sessionId={sessionId} autoFocus={true} + supportsCopyPaste={false} />
diff --git a/src/web-ui/src/app/hooks/useWindowControls.ts b/src/web-ui/src/app/hooks/useWindowControls.ts index a25234b43..4e0545e81 100644 --- a/src/web-ui/src/app/hooks/useWindowControls.ts +++ b/src/web-ui/src/app/hooks/useWindowControls.ts @@ -7,6 +7,7 @@ import { createLogger } from '@/shared/utils/logger'; import { sendDebugProbe } from '@/shared/utils/debugProbe'; import { nowMs } from '@/shared/utils/timing'; import { useI18n } from '@/infrastructure/i18n'; +import {workspaceAPI} from "@/infrastructure"; import { isMacOSDesktopRuntime, supportsNativeWindowControls } from '@/infrastructure/runtime'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { @@ -52,11 +53,11 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { const [isMaximized, setIsMaximized] = useState(false); // OS fullscreen state: entire Desktop window fullscreen, not panel fullscreen. const [isFullscreen, setIsFullscreen] = useState(false); - + // Debounce guard to prevent rapid toggles const isMaximizeInProgress = useRef(false); const isFullscreenInProgress = useRef(false); - + // Skip state updates during manual operations const shouldSkipStateUpdate = useRef(false); @@ -217,8 +218,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { const focusSnapshot = captureFocusedEditable(); try { - const appWindow = getCurrentWindow(); - await appWindow.minimize(); + await workspaceAPI.handle_min_window(); // Ensure input is usable after restore // Listen for restore @@ -260,12 +260,13 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { appWindow = getCurrentWindow(); + // Optimization: skip isVisible check; query maximized directly. // If minimized, user restores via taskbar instead of double-clicking header. // Check current state to avoid duplicate toggles. let currentMaximized = false; try { - currentMaximized = await appWindow.isMaximized(); + currentMaximized = await workspaceAPI.window_is_maximized(); } catch (error) { log.warn('Failed to get maximized state, assuming not maximized', error); currentMaximized = false; @@ -279,10 +280,10 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { // Toggle maximize/restore if (currentMaximized) { - await appWindow.unmaximize(); + await workspaceAPI.handle_restore_window(); updateState(false); } else { - await appWindow.maximize(); + await workspaceAPI.handle_max_window(); updateState(true); } @@ -362,8 +363,7 @@ export const useWindowControls = (options?: { isToolbarMode?: boolean }) => { if (!canUseNativeWindowControls) return; try { - const appWindow = getCurrentWindow(); - await appWindow.close(); + await workspaceAPI.close_window() } catch (error) { log.error('Failed to close window', error); notificationService.error(t('window.closeFailed', { error: formatErrorMessage(error) })); diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 154a89349..5347b6edd 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -9,7 +9,6 @@ */ import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from 'react'; -import { open } from '@tauri-apps/plugin-dialog'; import { LoaderCircle } from 'lucide-react'; import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; @@ -18,7 +17,8 @@ import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap'; import { useApp } from '../hooks/useApp'; import { useSceneStore } from '../stores/sceneStore'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; -import { configManager } from '@/infrastructure/config'; +import { configManager } from '@/infrastructure'; +import { sessionStorageAdapter } from '@/shared/utils/sessionStorageAdapter'; type TransitionDirection = 'entering' | 'returning' | null; import { FlowChatManager } from '../../flow_chat/services/FlowChatManager'; @@ -209,11 +209,7 @@ const AppLayout: React.FC = ({ className = '' }) => { }>>([]); const handleOpenProject = useCallback(async () => { try { - const selected = await open({ - directory: true, - multiple: false, - title: t('header.selectProjectDirectory'), - }); + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected && typeof selected === 'string') { await openWorkspace(selected); @@ -256,10 +252,9 @@ const AppLayout: React.FC = ({ className = '' }) => { void (async () => { try { const { listen } = await import('@tauri-apps/api/event'); - const { open } = await import('@tauri-apps/plugin-dialog'); unlistenFns.push(await listen('bitfun_menu_open_project', async () => { try { - const selected = await open({ directory: true, multiple: false }) as string; + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected) await openWorkspace(selected); } catch {} })); @@ -279,10 +274,10 @@ const AppLayout: React.FC = ({ className = '' }) => { // Always initialize FlowChat so historical sessions list even when SSH is not connected yet. try { const explicitPreferredMode = - sessionStorage.getItem('bitfun:flowchat:preferredMode') || + sessionStorageAdapter.getItem('bitfun:flowchat:preferredMode') || undefined; if (explicitPreferredMode) { - sessionStorage.removeItem('bitfun:flowchat:preferredMode'); + sessionStorageAdapter.removeItem('bitfun:flowchat:preferredMode'); } const initializationPreferredMode = @@ -317,9 +312,9 @@ const AppLayout: React.FC = ({ className = '' }) => { ensureAssistantBootstrapForWorkspace(currentWorkspace, activeSessionId); } - const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); + const pendingDescription = sessionStorageAdapter.getItem('pendingProjectDescription'); if (pendingDescription && pendingDescription.trim()) { - sessionStorage.removeItem('pendingProjectDescription'); + sessionStorageAdapter.removeItem('pendingProjectDescription'); setTimeout(async () => { try { @@ -345,9 +340,9 @@ const AppLayout: React.FC = ({ className = '' }) => { }, 500); } - const pendingSettings = sessionStorage.getItem('pendingOpenSettings'); + const pendingSettings = sessionStorageAdapter.getItem('pendingOpenSettings'); if (pendingSettings) { - sessionStorage.removeItem('pendingOpenSettings'); + sessionStorageAdapter.removeItem('pendingOpenSettings'); setTimeout(async () => { try { const { quickActions } = await import('@/shared/services/ide-control'); diff --git a/src/web-ui/src/app/layout/panelConfig.ts b/src/web-ui/src/app/layout/panelConfig.ts index 3c2f07487..f5d6ab10c 100644 --- a/src/web-ui/src/app/layout/panelConfig.ts +++ b/src/web-ui/src/app/layout/panelConfig.ts @@ -13,6 +13,7 @@ * - expanded: expanded mode (wide content, more information) */ +import { storage } from '@/shared/utils/storageAdapter'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('PanelConfig'); @@ -194,7 +195,7 @@ export const STORAGE_KEYS = { */ export function savePanelWidth(key: string, width: number): void { try { - localStorage.setItem(key, String(width)); + storage.setItem(key, String(width)); } catch (e) { log.warn('Failed to save panel width', { key, width, error: e }); } @@ -205,7 +206,7 @@ export function savePanelWidth(key: string, width: number): void { */ export function loadPanelWidth(key: string, defaultValue: number): number { try { - const stored = localStorage.getItem(key); + const stored = storage.getItem(key); if (stored) { const parsed = parseInt(stored, 10); if (!isNaN(parsed) && parsed > 0) { diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index 04971c75a..3624dc755 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -6,13 +6,14 @@ */ import { useLayoutEffect, useRef, useEffect, RefObject } from 'react'; import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; -import { open as dialogOpen, save as dialogSave, message as dialogMessage } from '@tauri-apps/plugin-dialog'; +import { save as dialogSave, message as dialogMessage } from '@tauri-apps/plugin-dialog'; import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; import { buildMiniAppThemeVars } from '../utils/buildMiniAppThemeVars'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useI18n } from '@/infrastructure/i18n'; +import {workspaceAPI} from "@/infrastructure"; import type { MiniAppRunScope } from '../customization/miniAppCustomizationTypes'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; @@ -185,7 +186,7 @@ export function useMiniAppBridge( return; } if (method === 'dialog.open') { - reply(await dialogOpen(params as unknown as Parameters[0])); + reply(await workspaceAPI.open_oh_file_dialog()); return; } if (method === 'dialog.save') { diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index 6ef7d8a6b..1083546e8 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -9,7 +9,6 @@ import { Tag, Trash2, } from 'lucide-react'; -import { open } from '@tauri-apps/plugin-dialog'; import { useSceneManager } from '@/app/hooks/useSceneManager'; import MiniAppCard from '../components/MiniAppCard'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; @@ -33,6 +32,7 @@ import { useMiniAppStore } from '../miniAppStore'; import { useI18n } from '@/infrastructure/i18n'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; import './MiniAppGalleryView.scss'; +import {workspaceAPI} from "@/infrastructure"; const log = createLogger('MiniAppGalleryView'); @@ -169,12 +169,7 @@ const MiniAppGalleryView: React.FC = () => { const handleAddFromFolder = async () => { try { - const selected = await open({ - directory: true, - multiple: false, - title: t('selectFolderTitle'), - }); - const path = Array.isArray(selected) ? selected[0] : selected; + const path = await workspaceAPI.open_oh_file_dialog(); if (!path) return; setLoading(true); diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.scss b/src/web-ui/src/app/scenes/skills/SkillsScene.scss index 47d3a4262..ab9574706 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.scss +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.scss @@ -1064,6 +1064,12 @@ &__detail-link { text-decoration: underline; text-underline-offset: 2px; + background: none; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + text-align: left; } &__path-input { diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 21de8d894..e8121e619 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -23,6 +23,7 @@ import { Badge, Button, ConfirmDialog, Input, Modal, Search, Select } from '@/co import { GalleryDetailModal } from '@/app/components'; import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; import { workspaceAPI } from '@/infrastructure/api'; +import { systemAPI } from '@/infrastructure/api'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useNotification } from '@/shared/notification-system'; import { isRemoteWorkspace } from '@/shared/types'; @@ -38,6 +39,12 @@ import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefre const log = createLogger('SkillsScene'); +function formatDisplayPath(path: string): string { + return path.replace( + '/data/storage/el2/base/files/bitfun', + '/storage/Users/currentUser/appdata/el2/base/com.huawei.BitFun/files/bitfun' + ); +} type SkillTab = 'installed' | 'discover'; const INSTALLED_PAGE_SIZE = 12; @@ -667,12 +674,12 @@ const SkillsScene: React.FC = () => { type="button" className="bitfun-skills-scene__detail-path-btn" title={t('list.item.openPathInExplorer')} - onClick={() => void handleRevealSkillPath(selectedInstalledSkill.path)} + onClick={() => void handleRevealSkillPath(formatDisplayPath(selectedInstalledSkill.path))} > - {selectedInstalledSkill.path} + {formatDisplayPath(selectedInstalledSkill.path)} ) : ( - {selectedInstalledSkill.path} + {formatDisplayPath(selectedInstalledSkill.path)} )}
@@ -692,19 +699,18 @@ const SkillsScene: React.FC = () => {
) : null} - {selectedMarketSkill?.url ? ( -
- {t('market.detail.linkLabel')} - - {selectedMarketSkill.url} - -
- ) : null} + {selectedMarketSkill?.url ? ( +
+ {t('market.detail.linkLabel')} + +
+ ) : null} { try { - const selected = await open({ - directory: true, - multiple: false, - title: t('form.path.label'), - }); + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected) { setFormPath(selected as string); } diff --git a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx index 87deb411c..b2f4a0a8d 100644 --- a/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx +++ b/src/web-ui/src/app/scenes/terminal/TerminalScene.tsx @@ -44,6 +44,7 @@ const TerminalScene: React.FC = ({ isActive = true }) => { showStatusBar onExit={handleExit} onClose={handleClose} + supportsCopyPaste={false} /> ) : (
diff --git a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx index ca5ac675f..393440064 100644 --- a/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx +++ b/src/web-ui/src/app/scenes/welcome/WelcomeScene.tsx @@ -19,6 +19,7 @@ import type { SceneTabId } from '@/app/components/SceneBar/types'; import type { WorkspaceInfo } from '@/shared/types'; import { getRecentWorkspaceLineParts } from '@/shared/utils/recentWorkspaceDisplay'; import './WelcomeScene.scss'; +import {workspaceAPI} from "@/infrastructure"; const log = createLogger('WelcomeScene'); @@ -55,12 +56,7 @@ const WelcomeScene: React.FC = () => { const handleOpenFolder = useCallback(async () => { try { setIsSelecting(true); - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ - directory: true, - multiple: false, - title: t('startup.selectWorkspaceDirectory'), - }); + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected && typeof selected === 'string') { await openWorkspace(selected); openScene('session' as SceneTabId); diff --git a/src/web-ui/src/app/services/AppManager.ts b/src/web-ui/src/app/services/AppManager.ts index 8aa443a0c..13983fb5e 100644 --- a/src/web-ui/src/app/services/AppManager.ts +++ b/src/web-ui/src/app/services/AppManager.ts @@ -18,6 +18,7 @@ import { import { globalEventBus } from '../../infrastructure/event-bus'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; +import { storage } from '@/shared/utils/storageAdapter'; const log = createLogger('AppManager'); @@ -363,13 +364,13 @@ export class AppManager implements IAppManager { private clearPersistedPanelState(): void { try { // Clear AppManager persisted state - localStorage.removeItem('bitfun-app-state'); + storage.removeItem('bitfun-app-state'); // Clear other potential panel state keys - localStorage.removeItem('BitFun-left-panel-width'); - localStorage.removeItem('BitFun-left-panel-collapsed'); - localStorage.removeItem('BitFun-right-panel-collapsed'); - localStorage.removeItem('right-panel-collapsed'); + storage.removeItem('BitFun-left-panel-width'); + storage.removeItem('BitFun-left-panel-collapsed'); + storage.removeItem('BitFun-right-panel-collapsed'); + storage.removeItem('right-panel-collapsed'); } catch (error) { log.warn('Failed to clear persisted panel state', error); } diff --git a/src/web-ui/src/component-library/components/WindowControls/WindowControls.tsx b/src/web-ui/src/component-library/components/WindowControls/WindowControls.tsx index aa8652352..479ebdd9a 100644 --- a/src/web-ui/src/component-library/components/WindowControls/WindowControls.tsx +++ b/src/web-ui/src/component-library/components/WindowControls/WindowControls.tsx @@ -3,8 +3,6 @@ */ import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Tooltip } from '../Tooltip'; import './WindowControls.scss'; export interface WindowControlsProps extends React.HTMLAttributes { @@ -29,122 +27,7 @@ export interface WindowControlsProps extends React.HTMLAttributes = ({ - onMinimize, - onMaximize, - onClose, - showMinimize = true, - showMaximize = true, - showClose = true, - disabled = false, - isMaximized = false, - minimizeIcon, - maximizeIcon, - restoreIcon, - closeIcon, - className = '', - 'data-testid-minimize': testIdMinimize, - 'data-testid-maximize': testIdMaximize, - 'data-testid-close': testIdClose, - ...props -}) => { - const { t } = useTranslation('common'); - const defaultMinimizeIcon = ( - - - - ); - - const defaultMaximizeIcon = ( - - - - ); - - const defaultRestoreIcon = ( - - - - - ); - - const defaultCloseIcon = ( - - - - - ); - - return ( -
- {showMinimize && ( - - - - )} - - {showMaximize && ( - - - - )} - - {showClose && ( - - - - )} -
- ); +export const WindowControls: React.FC = ( +) => { + return (
); }; diff --git a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx index d879106bb..2c36c2422 100644 --- a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx +++ b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx @@ -23,6 +23,7 @@ import { Download, } from 'lucide-react'; import './RemoteFileBrowser.scss'; +import {workspaceAPI} from "@/infrastructure"; interface RemoteFileBrowserProps { connectionId: string; @@ -311,12 +312,7 @@ export const RemoteFileBrowser: React.FC = ({ setError(t('ssh.remote.transferNeedsDesktop')); return; } - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ - title: t('ssh.remote.uploadDialogTitle'), - multiple: true, - directory: false, - }); + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected === null) return; const paths = Array.isArray(selected) ? selected : [selected]; if (paths.length === 0) return; diff --git a/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts b/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts index 242004bbc..dd7fe2bee 100644 --- a/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts +++ b/src/web-ui/src/features/ssh-remote/pickSshPrivateKeyPath.ts @@ -2,22 +2,14 @@ * Native file picker for SSH private keys; default folder is ~/.ssh (via Tauri homeDir + join). */ -import { open } from '@tauri-apps/plugin-dialog'; -import { homeDir, join } from '@tauri-apps/api/path'; +import {workspaceAPI} from "@/infrastructure"; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('pickSshPrivateKeyPath'); -export async function pickSshPrivateKeyPath(options: { title?: string } = {}): Promise { +export async function pickSshPrivateKeyPath(_options: { title?: string } = {}): Promise { try { - const home = await homeDir(); - const defaultPath = await join(home, '.ssh'); - const selected = await open({ - multiple: false, - directory: false, - defaultPath, - title: options.title, - }); + const selected = await workspaceAPI.open_oh_file_dialog(); return selected ?? null; } catch (e) { log.error('SSH private key file picker failed', e); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a35ea5be4..3409d439f 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -67,6 +67,7 @@ import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity'; import { shouldBlockDeepReviewCommand } from '../utils/deepReviewCommandGuard'; import { deriveDeepReviewSessionConcurrencyGuard } from '../utils/deepReviewCapacityGuard'; import './ChatInput.scss'; +import { sessionStorageAdapter } from '@/shared/utils/sessionStorageAdapter'; const log = createLogger('ChatInput'); @@ -846,7 +847,7 @@ export const ChatInput: React.FC = ({ log.debug('Session switched, syncing mode', { sessionId, mode }); dispatchMode({ type: 'SET_CURRENT_MODE', payload: mode }); try { - sessionStorage.setItem('bitfun:flowchat:lastMode', mode); + sessionStorageAdapter.setItem('bitfun:flowchat:lastMode', mode); } catch { // ignore } @@ -876,7 +877,7 @@ export const ChatInput: React.FC = ({ }); dispatchMode({ type: 'SET_CURRENT_MODE', payload: nextMode }); try { - sessionStorage.setItem('bitfun:flowchat:lastMode', nextMode); + sessionStorageAdapter.setItem('bitfun:flowchat:lastMode', nextMode); } catch { // ignore } @@ -1884,7 +1885,7 @@ export const ChatInput: React.FC = ({ }); try { - sessionStorage.setItem('bitfun:flowchat:lastMode', modeId); + sessionStorageAdapter.setItem('bitfun:flowchat:lastMode', modeId); } catch { // ignore } diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index 2283a6ab8..4cc473539 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { FolderOpen, ChevronDown, Check, GitBranch } from 'lucide-react'; -import { gitAPI } from '../../infrastructure/api'; +import { gitAPI ,workspaceAPI} from '../../infrastructure/api'; import type { GitWorkState } from '../../infrastructure/api/service-api/StartchatAgentAPI'; import { useApp } from '../../app/hooks/useApp'; import { createLogger } from '@/shared/utils/logger'; @@ -151,8 +151,8 @@ export const WelcomePanel: React.FC = ({ try { setWorkspaceDropdownOpen(false); setIsSelectingWorkspace(true); - const { open } = await import('@tauri-apps/plugin-dialog'); - const selected = await open({ directory: true, multiple: false }); + + const selected = await workspaceAPI.open_oh_file_dialog(); if (selected && typeof selected === 'string') await openWorkspace(selected); } catch (err) { log.warn('Failed to open workspace folder', err); diff --git a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx index 2fd365ff8..31aad6e89 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExportImageButton.tsx @@ -2,16 +2,17 @@ * Export dialog turns as long images. * Uses React rendering to match FlowChat styles. * Uses modern-screenshot (fork of html-to-image with better CSS var / font / CORS handling). + * + * NOTE: Temporarily disabled — the export button is hidden. Keep the export + * infrastructure in place for when the underlying capture/save issues are resolved. */ import React, { useState, useCallback, useRef } from 'react'; import { createRoot } from 'react-dom/client'; -import { Image, Loader2 } from 'lucide-react'; import { FlowChatStore } from '../../store/FlowChatStore'; import { notificationService } from '@/shared/notification-system'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; -import { Tooltip } from '@/component-library'; import type { DialogTurn, FlowTextItem, FlowToolItem, FlowThinkingItem } from '../../types/flow-chat'; import { i18nService } from '@/infrastructure/i18n'; import { workspaceAPI } from '@/infrastructure/api'; @@ -534,17 +535,13 @@ export const ExportImageButton: React.FC = ({ } }, [getDialogTurn]); - return ( - - - - ); + // Currently unused — kept for re-enable. + void (handleExport as unknown as React.MouseEventHandler); + void (isExporting as boolean); + void (isExportingRef as React.MutableRefObject); + void (className as string); + + return null; }; ExportImageButton.displayName = 'ExportImageButton'; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index ecfad920e..5911a51b9 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -42,6 +42,7 @@ import { import type { WorkspaceInfo } from '@/shared/types'; import { sessionBelongsToWorkspaceNavRow } from '../utils/sessionOrdering'; import { sessionMatchesWorkspace } from '../utils/workspaceScope'; +import { storage } from '@/shared/utils/storageAdapter'; const log = createLogger('FlowChatStore'); const VALID_AGENT_TYPES = new Set(['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']); @@ -74,16 +75,21 @@ export class FlowChatStore { ]; keysToRemove.forEach(key => { - if (localStorage.getItem(key)) { - localStorage.removeItem(key); + if (storage.getItem(key)) { + storage.removeItem(key); } }); + try { + const keys = storage.getKeys(); + keys.forEach(key => { + if (key.startsWith('bitfun-session-')) { + storage.removeItem(key); + } + }) + } catch (e) { + log.warn("Failed to clear session key"); + } - Object.keys(localStorage).forEach(key => { - if (key.startsWith('bitfun-session-')) { - localStorage.removeItem(key); - } - }); } catch (error) { log.warn('Failed to clear old storage data', error); } @@ -1748,7 +1754,7 @@ export class FlowChatStore { if (isLegacyPersistedBtwSession(metadata)) { return; } - + let maxContextTokens = 128128; try { const { configManager } = await import('@/infrastructure/config/services/ConfigManager'); @@ -1785,7 +1791,7 @@ export class FlowChatStore { if (prev.sessions.has(metadata.sessionId)) { return prev; } - + const rawAgentType = metadata.agentType || 'agentic'; const validatedAgentType = isValidPersistedAgentType(rawAgentType) ? rawAgentType : 'agentic'; @@ -1899,7 +1905,7 @@ export class FlowChatStore { sessions: newSessions, }; }); - + // Reset state machine to IDLE after loading history // This handles the case where restoreSession triggered events that left the state machine in PROCESSING stateMachineManager.reset(sessionId); diff --git a/src/web-ui/src/flow_chat/store/inputHistoryStore.ts b/src/web-ui/src/flow_chat/store/inputHistoryStore.ts index c581c5b50..90663342e 100644 --- a/src/web-ui/src/flow_chat/store/inputHistoryStore.ts +++ b/src/web-ui/src/flow_chat/store/inputHistoryStore.ts @@ -4,8 +4,9 @@ * History is now session-scoped - each session maintains its own input history. */ +import { storage } from '@/shared/utils/storageAdapter'; import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { createJSONStorage, persist } from 'zustand/middleware'; export interface InputHistoryState { /** Map of sessionId to list of previously sent messages (most recent first) */ @@ -92,6 +93,7 @@ export const useInputHistoryStore = create()( { name: 'bitfun-input-history', version: 2, // Bump version to migrate from old format + storage: createJSONStorage(() => storage), migrate: (persistedState: any, version: number) => { if (version < 2) { // Migrate from old global format to new session-scoped format diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx index e1dd1d7b0..113696577 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx @@ -24,6 +24,9 @@ export interface CompactToolCardProps { isExpanded?: boolean; /** Card click callback */ onClick?: (e: React.MouseEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; /** Custom class name */ className?: string; /** Whether clickable */ @@ -37,18 +40,20 @@ export interface CompactToolCardProps { export const CompactToolCard: React.FC = ({ status, isExpanded = false, - onClick, + onMouseDown, + onMouseUp, + onMouseMove, className = '', clickable = false, header, expandedContent, }) => { const handleWrapperClick = (event: React.MouseEvent) => { - if (!onClick || shouldIgnoreCardToggleClick(event)) { + if (!onMouseUp || shouldIgnoreCardToggleClick(event)) { return; } - onClick(event); + onMouseUp(event); }; const loadingShimmer = @@ -67,7 +72,7 @@ export const CompactToolCard: React.FC = ({ className={`compact-tool-card-wrapper--expanded-card ${className}`.trim()} header={header} expandedContent={expandedContent} - headerExpandAffordance={clickable || Boolean(onClick)} + headerExpandAffordance={clickable || Boolean(onMouseUp)} /> ); } @@ -78,7 +83,9 @@ export const CompactToolCard: React.FC = ({ >
{header} diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index ec13a8b90..8a5023cd8 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -93,6 +93,7 @@ export const DefaultToolCard: React.FC = ({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status, requiresConfirmation, userConfirmed } = toolItem; const [isExpanded, setIsExpanded] = useState(false); + const [shouldExpand, setShouldExpand] = useState(true); const toolId = toolItem.id ?? toolCall?.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -128,14 +129,25 @@ export const DefaultToolCard: React.FC = ({ onReject?.(); }; + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [applyExpandedState, canExpand, isExpanded, onExpand,shouldExpand, setShouldExpand]); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false); + }, [applyExpandedState, canExpand, isExpanded, onExpand,shouldExpand, setShouldExpand]); + const handleToggleExpand = useCallback(() => { if (!canExpand) return; const nextExpanded = !isExpanded; - applyExpandedState(isExpanded, nextExpanded, setIsExpanded, { - onExpand, + if (shouldExpand) { + applyExpandedState(isExpanded, nextExpanded, setIsExpanded, { + onExpand, }); - }, [applyExpandedState, canExpand, isExpanded, onExpand]); + } + setShouldExpand(true); + }, [applyExpandedState, canExpand, isExpanded, onExpand, shouldExpand, setShouldExpand]); const getStatusText = () => { if (requiresConfirmation && !userConfirmed) { @@ -213,7 +225,9 @@ export const DefaultToolCard: React.FC = ({ = ({ return t('toolCards.git.executionFailed'); }; + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [hasOutput, isFailed, toggleExpanded, shouldExpand, setShouldExpand]); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false); + }, [hasOutput, isFailed, toggleExpanded, shouldExpand, setShouldExpand]); + const handleCardClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('.tool-card-header-actions, .git-action-buttons, .terminal-header-actions')) { return; } - if (hasOutput || isFailed) { + if ((hasOutput || isFailed) && shouldExpand) { toggleExpanded(); } - }, [hasOutput, isFailed, toggleExpanded]); + setShouldExpand(true); + }, [hasOutput, isFailed, toggleExpanded, shouldExpand, setShouldExpand]); const renderStatusIcon = () => { if (isLoading) { @@ -417,7 +428,9 @@ export const GitToolDisplay: React.FC = ({ = ({ const searchPath = getSearchPath(); const hasDetails = status === 'completed' && files.length > 0; const hasResultData = toolResult?.result !== undefined && toolResult?.result !== null; + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseMove= useCallback(() => { + setShouldExpand(false); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); + + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); const handleClick = useCallback(() => { - if (hasDetails) { + if (hasDetails && shouldExpand) { applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + setShouldExpand(true); + }, [applyExpandedState, hasDetails, isExpanded, onExpand,shouldExpand, setShouldExpand]); const renderContent = () => { if (status === 'completed') { @@ -180,7 +190,9 @@ export const GlobSearchDisplay: React.FC = ({ = ({ const hasDetails = status === 'completed' && stats.matches > 0; const hasResultData = toolResult?.result !== undefined && toolResult?.result !== null; + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); + + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); + const handleClick = useCallback(() => { - if (hasDetails) { + if (hasDetails && shouldExpand) { applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + setShouldExpand(true); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); const renderContent = () => { if (status === 'completed') { @@ -135,7 +146,9 @@ export const GrepSearchDisplay: React.FC = ({ = ({ const hasDetails = status === 'completed' && entries.length > 0; const hasResultData = toolResult?.result !== undefined && toolResult?.result !== null; + const [shouldExpand, setShouldExpand] = useState(true); + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); const handleClick = useCallback(() => { - if (hasDetails) { + if (hasDetails && shouldExpand) { applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand, }); } - }, [applyExpandedState, hasDetails, isExpanded, onExpand]); + }, [applyExpandedState, hasDetails, isExpanded, onExpand, shouldExpand, setShouldExpand]); const renderContent = () => { if (status === 'completed') { @@ -176,7 +184,9 @@ export const LSDisplay: React.FC = ({ = ({ thin return t('toolCards.think.thinkingCharacters', { count: content.length }); }, [content, t]); + const shouldExpand = useRef(true); + + const handleMouseDown = () => { + shouldExpand.current = true; + }; + + const handleMouseMove = () => { + shouldExpand.current = false; + }; + const handleToggleClick = () => { - const nextExpanded = !isExpanded; - userToggledRef.current = true; - applyExpandedState(isExpanded, nextExpanded, setIsExpanded); + if (shouldExpand.current) { + const nextExpanded = !isExpanded; + userToggledRef.current = true; + applyExpandedState(isExpanded, nextExpanded, setIsExpanded); + } + shouldExpand.current = true; }; const headerLabel = (isExpanded @@ -113,7 +126,9 @@ export const ModelThinkingDisplay: React.FC = ({ thin
{headerLabel} diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index d1ee33108..707e17631 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -2,7 +2,7 @@ * Compact display for the read_file tool. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { Check, FileText, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { IconButton } from '../../component-library'; @@ -152,6 +152,23 @@ export const ReadFileDisplay: React.FC = React.memo(({ return null; }; + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown = () => { + setShouldExpand(true); + } + + const handleMouseMove = () => { + setShouldExpand(false); + } + + const handleMouseUp = () => { + if (shouldExpand && canOpenFile) { + handleOpenInEditor(); + } + setShouldExpand(true); + } + const renderActions = () => { if (!showConfirmationActions) { return undefined; @@ -203,7 +220,9 @@ export const ReadFileDisplay: React.FC = React.memo(({ canOpenFile && handleOpenInEditor()} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} className="read-file-card" clickable={canOpenFile} header={ diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx index a95a95ede..3495d465c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx @@ -101,6 +101,23 @@ export const SessionControlToolCard: React.FC = React.memo(({ } }; + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown = () => { + setShouldExpand(true); + } + + const handleMouseMove = () => { + setShouldExpand(false); + } + + const handleMouseUp = () => { + if (shouldExpand && hasDetails) { + applyExpandedState(isExpanded, !isExpanded, setIsExpanded); + } + setShouldExpand(true); + } + const renderContent = () => { const label = getActionLabel(); @@ -261,11 +278,9 @@ export const SessionControlToolCard: React.FC = React.memo(({ { - if (hasDetails) { - applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - } - }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} className="session-control-card" clickable={hasDetails} header={( diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx index 21ee458a5..3f817b392 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx @@ -60,6 +60,23 @@ export const SessionMessageToolCard: React.FC = React.memo(({ const targetLabel = targetSessionId || t('toolCards.sessionMessage.unknownSession'); + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown = () => { + setShouldExpand(true); + } + + const handleMouseMove = () => { + setShouldExpand(false); + } + + const handleMouseUp = () => { + if (shouldExpand && hasDetails) { + applyExpandedState(isExpanded, !isExpanded, setIsExpanded); + } + setShouldExpand(true); + } + const renderContent = () => { if (status === 'completed') { return <>{t('toolCards.sessionMessage.messageAccepted', { session: targetLabel })}; @@ -131,11 +148,9 @@ export const SessionMessageToolCard: React.FC = React.memo(({ { - if (hasDetails) { - applyExpandedState(isExpanded, !isExpanded, setIsExpanded); - } - }} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} className="session-message-card" clickable={hasDetails} header={( diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 646b30fe4..47fcddac0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -430,14 +430,26 @@ export const TerminalToolCard: React.FC = ({ createTerminalTab(terminalSessionId, terminalName); }, [terminalSessionId]); + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown = useCallback(() => { + setShouldExpand(true); + }, [toggleExpanded, shouldExpand, setShouldExpand]); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false); + }, [toggleExpanded, shouldExpand, setShouldExpand]); + const handleCardClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('.tool-card-header-actions, .terminal-action-btn, .terminal-confirm-actions')) { return; } - - toggleExpanded(); - }, [toggleExpanded]); + if (shouldExpand) { + toggleExpanded(); + } + setShouldExpand(true); + }, [toggleExpanded, shouldExpand, setShouldExpand]); const renderLoadingStatusIcon = () => { if (viewState.isLoading) { @@ -475,6 +487,7 @@ export const TerminalToolCard: React.FC = ({ successMessage={t('toolCards.terminal.commandCopied')} failureMessage={t('toolCards.terminal.copyCommandFailed')} ariaLabel={t('toolCards.terminal.copyCommand')} + showSuccessNotification={false} /> ); @@ -659,7 +672,9 @@ export const TerminalToolCard: React.FC = ({ = ({ @@ -41,11 +42,13 @@ export const ToolCardCopyAction: React.FC = ({ ariaLabel, className, disabled, + showSuccessNotification }) => { const { copied, copy } = useCopyTextAction({ getText, successMessage, failureMessage, + showSuccessNotification }); return ( diff --git a/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx b/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx index ed92410cd..6930115ee 100644 --- a/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx @@ -78,13 +78,24 @@ export const WebSearchCard: React.FC = ({ const hasSummary = !hasResults && searchResults && searchResults.summary; const isExpandable = status === 'completed' && (hasResults || hasSummary); + const [shouldExpand, setShouldExpand] = useState(true); + + const handleMouseDown= useCallback(() => { + setShouldExpand(true); + }, [applyExpandedState, isExpandable, isExpanded, onExpand, shouldExpand, setShouldExpand]); + + const handleMouseMove = useCallback(() => { + setShouldExpand(false) + }, [applyExpandedState, isExpandable, isExpanded, onExpand, shouldExpand, setShouldExpand]); + const handleClick = useCallback(() => { - if (isExpandable) { + if (isExpandable && shouldExpand) { applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand, }); } - }, [applyExpandedState, isExpandable, isExpanded, onExpand]); + setShouldExpand(true); + }, [applyExpandedState, isExpandable, isExpanded, onExpand, shouldExpand, setShouldExpand]); const renderContent = () => { if (status === 'completed') { @@ -163,7 +174,9 @@ export const WebSearchCard: React.FC = ({ (null); @@ -42,7 +44,10 @@ export function useCopyTextAction({ } setCopied(true); - notificationService.success(successMessage, { duration: resetMs }); + if (showSuccessNotification){ + notificationService.success(successMessage, { duration: resetMs }); + } + if (resetTimerRef.current !== null) { window.clearTimeout(resetTimerRef.current); @@ -51,7 +56,7 @@ export function useCopyTextAction({ setCopied(false); resetTimerRef.current = null; }, resetMs); - }, [failureMessage, getText, resetMs, successMessage]); + }, [failureMessage, getText, resetMs, successMessage,showSuccessNotification]); return { copied, copy }; } diff --git a/src/web-ui/src/hooks/useModelConfigs.ts b/src/web-ui/src/hooks/useModelConfigs.ts index 960cb1bc4..35c5feb7d 100644 --- a/src/web-ui/src/hooks/useModelConfigs.ts +++ b/src/web-ui/src/hooks/useModelConfigs.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { ModelConfig } from '../shared/types'; import { modelConfigManager } from '../infrastructure/config/services/modelConfigs'; +import { storage } from '@/shared/utils/storageAdapter'; export const useModelConfigs = () => { const [configs, setConfigs] = useState([]); const [loading, setLoading] = useState(true); @@ -37,9 +38,9 @@ export const useCurrentModelConfig = (initialConfigId?: string) => { const setCurrentConfigWithPersistence = (config: ModelConfig | null) => { setCurrentConfig(config); if (config) { - localStorage.setItem(CURRENT_CONFIG_KEY, config.id); + storage.setItem(CURRENT_CONFIG_KEY, config.id); } else { - localStorage.removeItem(CURRENT_CONFIG_KEY); + storage.removeItem(CURRENT_CONFIG_KEY); } // Notify other components via storage event @@ -75,14 +76,14 @@ export const useCurrentModelConfig = (initialConfigId?: string) => { } if (!currentConfig) { - const savedConfigId = localStorage.getItem(CURRENT_CONFIG_KEY); + const savedConfigId = storage.getItem(CURRENT_CONFIG_KEY); const targetConfigId = initialConfigId || savedConfigId; if (targetConfigId) { const foundConfig = configs.find(c => c.id === targetConfigId); if (foundConfig) { setCurrentConfig(foundConfig); - localStorage.setItem(CURRENT_CONFIG_KEY, foundConfig.id); + storage.setItem(CURRENT_CONFIG_KEY, foundConfig.id); return; } } @@ -91,7 +92,7 @@ export const useCurrentModelConfig = (initialConfigId?: string) => { const firstConfig = configs[0]; if (firstConfig) { setCurrentConfig(firstConfig); - localStorage.setItem(CURRENT_CONFIG_KEY, firstConfig.id); + storage.setItem(CURRENT_CONFIG_KEY, firstConfig.id); } return; } @@ -102,10 +103,10 @@ export const useCurrentModelConfig = (initialConfigId?: string) => { const firstConfig = configs[0]; if (firstConfig) { setCurrentConfig(firstConfig); - localStorage.setItem(CURRENT_CONFIG_KEY, firstConfig.id); + storage.setItem(CURRENT_CONFIG_KEY, firstConfig.id); } else { setCurrentConfig(null); - localStorage.removeItem(CURRENT_CONFIG_KEY); + storage.removeItem(CURRENT_CONFIG_KEY); } } else { // Sync with latest version (config may have been edited) diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 73c63a9c4..07104f014 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -29,13 +29,13 @@ export class TauriTransportAdapter implements ITransportAdapter { private async doInitialize() { try { // Check if Tauri API is available - if (typeof window !== 'undefined' && !('__TAURI__' in window)) { - log.warn('Tauri API not available, running in non-Tauri environment'); - this.invokeFn = async () => { - throw new Error('Tauri API is not available. Make sure you are running in a Tauri environment.'); - }; - return; - } + // if (typeof window !== 'undefined' && !('__TAURI__' in window)) { + // log.warn('Tauri API not available, running in non-Tauri environment'); + // this.invokeFn = async () => { + // throw new Error('Tauri API is not available. Make sure you are running in a Tauri environment.'); + // }; + // return; + // } const tauriApi = await import('@tauri-apps/api/core'); this.invokeFn = tauriApi.invoke; diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index b30d27a90..5bda6708b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -135,6 +135,14 @@ class RemoteConnectAPIService { throw e; } } + async sendRemoteDialogStatus(is_open: boolean): Promise { + try { + await this.adapter.request('send_remote_connect_dialog_status', {isOpen: is_open}); + } catch (e) { + log.error('sendRemoteDialogStatus failed', e); + throw e; + } + } async getStatus(): Promise { try { diff --git a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts index 4cef0083d..41d7d8bc0 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SystemAPI.ts @@ -86,10 +86,17 @@ export class SystemAPI { async openExternal(url: string): Promise { try { - await openUrl(url); + // 尝试调用 open_external_ohos (OHOS平台会成功) + await api.invoke('open_external_ohos', {url}); } catch (error) { - log.error('Failed to open external URL', { url, error }); - throw new Error(`Failed to open external URL: ${error}`); + // 非 OHOS 平台会收到错误,然后回退到 openUrl + log.warn('open_external_ohos failed, falling back to openUrl', { url, error }); + try { + await openUrl(url); + } catch (error) { + log.error('Failed to open external URL', { url, error }); + throw new Error(`Failed to open external URL: ${error}`); + } } } diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index 39b68fc2f..2ddcf0d3c 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -897,7 +897,7 @@ export class WorkspaceAPI { } } - + async renameFile(oldPath: string, newPath: string): Promise { try { await api.invoke('rename_file', { @@ -924,7 +924,70 @@ export class WorkspaceAPI { } } - + async open_oh_file_dialog(): Promise { + try { + return await api.invoke("open_oh_file_dialog") + } catch (error) { + throw createTauriCommandError('open_oh_file_dialog', error) + } + } + + async window_is_minimized(): Promise { + try { + return await api.invoke("window_is_minimized") + } catch (error) { + throw createTauriCommandError('window_is_minimized', error) + } + } + + async window_start_dragging(): Promise { + try { + return await api.invoke("window_start_dragging") + } catch (error) { + throw createTauriCommandError('window_start_dragging', error) + } + } + + async close_window(): Promise { + try { + return await api.invoke("close_window") + } catch (error) { + throw createTauriCommandError('close_window', error) + } + } + + async window_is_maximized(): Promise { + try { + return await api.invoke("window_is_maximized") + } catch (error) { + throw createTauriCommandError('window_is_maximized', error) + } + } + + async handle_min_window(): Promise { + try { + return await api.invoke("handle_min_window") + } catch (error) { + throw createTauriCommandError('handle_min_window', error) + } + } + + async handle_max_window(): Promise { + try { + return await api.invoke("handle_max_window") + } catch (error) { + throw createTauriCommandError('handle_max_window', error) + } + } + + async handle_restore_window(): Promise { + try { + return await api.invoke("handle_restore_window") + } catch (error) { + throw createTauriCommandError('handle_restore_window', error) + } + } + async revealInExplorer(path: string): Promise { try { await api.invoke('reveal_in_explorer', { @@ -935,7 +998,17 @@ export class WorkspaceAPI { } } - + async setThemeMode(theme: string): Promise { + try { + await api.invoke('set_theme_mode', { + theme + }); + } catch (error) { + throw createTauriCommandError('set_theme_mode', error, { theme }); + } + } + + async startFileWatch(path: string, recursive?: boolean): Promise { try { await api.invoke('start_file_watch', { diff --git a/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts b/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts index d6c334237..7e5357128 100644 --- a/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts +++ b/src/web-ui/src/infrastructure/api/service-api/tauri-commands.ts @@ -64,7 +64,9 @@ export interface ImportConfigRequest { configData: any; } - +export interface OpenOhosPath { + path: string; +} export interface GetModelInfoRequest { modelId: string; diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index 35d4445f0..62aeeb52b 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -232,6 +232,14 @@ function BasicsLoggingSection() { const [openingFolder, setOpeningFolder] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + const getFormattedLogPath = useCallback(() => { + if (!runtimeInfo?.sessionLogDir) return ''; + return runtimeInfo.sessionLogDir.replace( + '/data/storage/el2/base/files/bitfun', + '/storage/Users/currentUser/appdata/el2/base/com.huawei.BitFun/files/bitfun' + ); + }, [runtimeInfo?.sessionLogDir]); + const levelOptions = useMemo( () => [ { value: 'trace', label: t('logging.levels.trace') }, @@ -321,7 +329,7 @@ function BasicsLoggingSection() { ); const handleOpenFolder = useCallback(async () => { - const folder = runtimeInfo?.sessionLogDir; + const folder = getFormattedLogPath(); if (!folder) { showMessage('error', t('logging.messages.pathUnavailable')); return; @@ -336,7 +344,7 @@ function BasicsLoggingSection() { } finally { setOpeningFolder(false); } - }, [runtimeInfo?.sessionLogDir, showMessage, t]); + }, [getFormattedLogPath, showMessage, t]); if (loading) { return ; @@ -385,7 +393,7 @@ function BasicsLoggingSection() { >
- {runtimeInfo?.sessionLogDir || '-'} + {getFormattedLogPath() || '-'}
+ {technicalDetails && ( + + )}
- +
- + {activeTaskNotifications.length > 0 && (
@@ -448,7 +458,7 @@ export const NotificationCenter: React.FC = () => {
) : ( <> - + {groupedHistory.today.length > 0 && (
{t('common:time.today')}
@@ -456,7 +466,7 @@ export const NotificationCenter: React.FC = () => {
)} - + {groupedHistory.yesterday.length > 0 && (
{t('common:time.yesterday')}
@@ -464,7 +474,7 @@ export const NotificationCenter: React.FC = () => {
)} - + {groupedHistory.earlier.length > 0 && (
{t('components:notificationCenter.groups.earlier')}
diff --git a/src/web-ui/src/shared/stores/contextStore.ts b/src/web-ui/src/shared/stores/contextStore.ts index 05925dcb8..215c64afd 100644 --- a/src/web-ui/src/shared/stores/contextStore.ts +++ b/src/web-ui/src/shared/stores/contextStore.ts @@ -1,24 +1,25 @@ - + import { create } from 'zustand'; -import { devtools, persist } from 'zustand/middleware'; +import { devtools, persist, StorageValue } from 'zustand/middleware'; import { ContextItem, ValidationResult } from '../types/context'; import { createLogger } from '@/shared/utils/logger'; +import { storage } from '@/shared/utils/storageAdapter'; const log = createLogger('ContextStore'); interface ContextState { - + contexts: ContextItem[]; - - + + validationStates: Map; - - + + validatingIds: Set; - + // Actions addContext: (item: ContextItem) => void; removeContext: (id: string) => void; @@ -35,35 +36,35 @@ export const useContextStore = create()( devtools( persist( (set, _get) => ({ - + contexts: [], validationStates: new Map(), validatingIds: new Set(), - - + + addContext: (item: ContextItem) => { set((state) => { - + if (state.contexts.some(c => c.id === item.id)) { log.warn('Context already exists', { id: item.id }); return state; } - + return { contexts: [...state.contexts, item] }; }, false, 'addContext'); }, - - + + removeContext: (id: string) => { set((state) => { const newValidationStates = new Map(state.validationStates); newValidationStates.delete(id); - + const newValidatingIds = new Set(state.validatingIds); newValidatingIds.delete(id); - + return { contexts: state.contexts.filter(c => c.id !== id), validationStates: newValidationStates, @@ -71,8 +72,8 @@ export const useContextStore = create()( }; }, false, 'removeContext'); }, - - + + clearContexts: () => { set({ contexts: [], @@ -80,24 +81,24 @@ export const useContextStore = create()( validatingIds: new Set() }, false, 'clearContexts'); }, - - + + updateValidation: (id: string, result: ValidationResult) => { set((state) => { const newValidationStates = new Map(state.validationStates); newValidationStates.set(id, result); - + const newValidatingIds = new Set(state.validatingIds); newValidatingIds.delete(id); - + return { validationStates: newValidationStates, validatingIds: newValidatingIds }; }, false, 'updateValidation'); }, - - + + setValidating: (id: string, validating: boolean) => { set((state) => { const newValidatingIds = new Set(state.validatingIds); @@ -106,36 +107,58 @@ export const useContextStore = create()( } else { newValidatingIds.delete(id); } - + return { validatingIds: newValidatingIds }; }, false, 'setValidating'); }, - - + + reorderContexts: (startIndex: number, endIndex: number) => { set((state) => { const newContexts = [...state.contexts]; const [removed] = newContexts.splice(startIndex, 1); newContexts.splice(endIndex, 0, removed); - + return { contexts: newContexts }; }, false, 'reorderContexts'); }, - - + + updateContext: (id: string, updates: Partial) => { set((state) => { - const contexts = state.contexts.map(c => + const contexts = state.contexts.map(c => c.id === id ? { ...c, ...updates } as ContextItem : c ); - + return { contexts }; }, false, 'updateContext'); } }), { name: 'bitfun-context-storage', - + storage: { + getItem(name: string) { + try { + return storage.getItem(name); + } catch (e) { + console.error(`Failed to get item from storage ${e}`); + } + }, + setItem(name: string, value: StorageValue) { + try { + return storage.setItem(name, JSON.stringify(value)); + } catch (e) { + console.error(`Failed to set item from storage ${e}`); + } + }, + removeItem(name: string) { + try { + return storage.removeItem(name); + } catch (e) { + console.error(`Failed to remove item from storage ${e}`); + } + } + }, serialize: (state: any) => { return JSON.stringify({ ...state.state, @@ -143,7 +166,7 @@ export const useContextStore = create()( validatingIds: Array.from(state.state.validatingIds) }); }, - + deserialize: (str: string) => { const parsed = JSON.parse(str); return { @@ -155,8 +178,8 @@ export const useContextStore = create()( } }; }, - - partialize: (state: any) => ({ + + partialize: (state: any) => ({ contexts: state.contexts.filter((ctx: any) => ctx.type !== 'image') }) } as any @@ -172,35 +195,35 @@ export const useContextStore = create()( export const selectContexts = (state: ContextState) => state.contexts; export const selectContextCount = (state: ContextState) => state.contexts.length; -export const selectContextById = (id: string) => (state: ContextState) => +export const selectContextById = (id: string) => (state: ContextState) => state.contexts.find(c => c.id === id); -export const selectValidationState = (id: string) => (state: ContextState) => +export const selectValidationState = (id: string) => (state: ContextState) => state.validationStates.get(id); -export const selectIsValidating = (id: string) => (state: ContextState) => +export const selectIsValidating = (id: string) => (state: ContextState) => state.validatingIds.has(id); -export const selectHasInvalidContexts = (state: ContextState) => +export const selectHasInvalidContexts = (state: ContextState) => Array.from(state.validationStates.values()).some(v => !v.valid); - + export const cleanupImageContextsFromStorage = () => { try { const storageKey = 'bitfun-context-storage'; - const stored = localStorage.getItem(storageKey); - + const stored = storage.getItem(storageKey); + if (stored) { const parsed = JSON.parse(stored); - + if (parsed.state && Array.isArray(parsed.state.contexts)) { const imageCount = parsed.state.contexts.filter((ctx: any) => ctx.type === 'image').length; - + if (imageCount > 0) { - + parsed.state.contexts = parsed.state.contexts.filter((ctx: any) => ctx.type !== 'image'); - - - localStorage.setItem(storageKey, JSON.stringify(parsed)); + + + storage.setItem(storageKey, JSON.stringify(parsed)); } } } diff --git a/src/web-ui/src/shared/utils/index.ts b/src/web-ui/src/shared/utils/index.ts index ca4d9cef4..53ebc1d80 100644 --- a/src/web-ui/src/shared/utils/index.ts +++ b/src/web-ui/src/shared/utils/index.ts @@ -13,3 +13,5 @@ export * from './debugProbe'; export * from './configConverter'; export * from './contextGenerator'; export * from './eventManager'; +export * from './storageAdapter'; +export * from './sessionStorageAdapter'; diff --git a/src/web-ui/src/shared/utils/sessionStorageAdapter.ts b/src/web-ui/src/shared/utils/sessionStorageAdapter.ts new file mode 100644 index 000000000..89e35d36c --- /dev/null +++ b/src/web-ui/src/shared/utils/sessionStorageAdapter.ts @@ -0,0 +1,87 @@ +class SessionStorageAdapter { + private isAvailable: boolean; + private memoryStorage: Map = new Map(); + + constructor() { + this.isAvailable = this.checkAvailability(); + } + + private checkAvailability(): boolean { + try { + const testkey = 'testkey'; + sessionStorage.setItem(testkey, testkey); + sessionStorage.removeItem(testkey); + return true; + } catch (e) { + console.error("SessionStorageAdapter check failed for session storage", e); + return false; + } + } + + getItem(key: string): string | null { + if (this.isAvailable) { + try { + return sessionStorage.getItem(key); + } catch (e) { + console.error(`Failed to get ${key} for `, e); + } + } + return this.memoryStorage.get(key) || null; + } + + setItem(key: string, value: string): void { + if (this.isAvailable) { + try { + sessionStorage.setItem(key, value); + this.memoryStorage.set(key, value); + return; + } catch (e) { + console.warn(`Failed to set ${key} Local storage not available`, e); + } + } + this.memoryStorage.set(key, value); + } + + removeItem(key: string): void { + if (this.isAvailable) { + try { + sessionStorage.removeItem(key); + this.memoryStorage.delete(key); + return; + } catch (e) { + console.warn(`Failed to delete ${key} Local storage not available`, e); + } + } + this.memoryStorage.delete(key); + } + + clear(): void { + if (this.isAvailable) { + try { + sessionStorage.clear(); + this.memoryStorage.clear(); + return; + } catch (e) { + console.warn(`Failed to clear storage. Local storage not available`, e); + } + } + this.memoryStorage.clear(); + } + + getKeys(): string[] { + if (this.isAvailable) { + try { + return Object.keys(sessionStorage); + } catch (e) { + console.warn(`Failed to get keys. Local storage not available`, e); + } + } + return Array.from(this.memoryStorage.keys()); + } + + hasKey(key: string): boolean { + return this.getItem(key) !== null; + } +} + +export const sessionStorageAdapter = new SessionStorageAdapter(); \ No newline at end of file diff --git a/src/web-ui/src/shared/utils/storageAdapter.ts b/src/web-ui/src/shared/utils/storageAdapter.ts new file mode 100644 index 000000000..9bb06adf5 --- /dev/null +++ b/src/web-ui/src/shared/utils/storageAdapter.ts @@ -0,0 +1,88 @@ +class StorageAdapter { + private isAvailable: boolean; + private memoryStorage: Map = new Map(); + + constructor() { + this.isAvailable = this.checkAvailability(); + } + + private checkAvailability(): boolean { + try { + const storageKey = '__storageKey__'; + localStorage.setItem(storageKey, storageKey); + localStorage.removeItem(storageKey); + return true; + } catch (e) { + console.error("SessionStorageAdapter check failed for session storage", e); + return false; + } + } + + + getItem(key: string): string | null { + if (this.isAvailable) { + try { + return localStorage.getItem(key); + } catch (e) { + console.error(`Failed to get ${key} for `, e); + } + } + return this.memoryStorage.get(key) || null; + } + + setItem(key: string, value: string): void { + if (this.isAvailable) { + try { + localStorage.setItem(key, value); + this.memoryStorage.set(key, value); + return; + } catch (e) { + console.warn(`Failed to set ${key} Local storage not available`, e); + } + } + this.memoryStorage.set(key, value); + } + + removeItem(key: string): void { + if (this.isAvailable) { + try { + localStorage.removeItem(key); + this.memoryStorage.delete(key); + return; + } catch (e) { + console.warn(`Failed to delete ${key} Local storage not available`, e); + } + } + this.memoryStorage.delete(key); + } + + clear(): void { + if (this.isAvailable) { + try { + localStorage.clear(); + this.memoryStorage.clear(); + return; + } catch (e) { + console.warn(`Failed to clear storage. Local storage not available`, e); + } + } + this.memoryStorage.clear(); + } + + getKeys(): string[] { + if (this.isAvailable) { + try { + return Object.keys(localStorage); + } catch (e) { + console.warn(`Failed to get keys. Local storage not available`, e); + } + } + return Array.from(this.memoryStorage.keys()); + } + + hasKey(key: string): boolean { + return this.getItem(key) !== null; + } +} + +export const storage = new StorageAdapter(); \ No newline at end of file diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index aa1972989..4070ced3e 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -46,7 +46,6 @@ import { EditorStatusBar } from './EditorStatusBar'; const log = createLogger('CodeEditor'); import { GoToLinePopover, - IndentPopover, EncodingPopover, LanguagePopover, } from './StatusBarPopovers'; @@ -413,6 +412,16 @@ const CodeEditor: React.FC = ({ return () => document.removeEventListener('mousedown', onMouseDown, true); }, [statusBarPopover]); + useEffect(() => { + if (!statusBarPopover) return; + const handleClosePreview = () => { + setStatusBarPopover(null); + setStatusBarAnchorRect(null); + }; + window.addEventListener('closePreview', handleClosePreview); + return () => window.removeEventListener('closePreview', handleClosePreview); + }, [statusBarPopover]); + // Sync font/config to editor when editorConfig changes (fixes late getConfig when opening from file tree) useEffect(() => { if (!monacoReady || !editorRef.current) return; @@ -1258,23 +1267,6 @@ const CodeEditor: React.FC = ({ if (editor && model) performJump(editor, model, line, column); }, [performJump]); - const handleIndentConfirm = useCallback((tabSize: number, insertSpaces: boolean) => { - const merged = { tab_size: tabSize, insert_spaces: insertSpaces }; - userIndentRef.current = merged; - setEditorConfig((prev) => ({ ...prev, ...merged })); - const editor = editorRef.current; - if (editor) { - editor.updateOptions({ tabSize, insertSpaces }); - } - // Async persistence, don't block UI update, don't trigger applyConfig override - configManager.getConfig('editor').then((config) => { - const fullMerged = { ...(config || {}), ...merged }; - return configManager.setConfig('editor', fullMerged); - }).catch((err) => { - log.warn('Failed to persist indent config', err); - }); - }, []); - const fetchFileMetadata = useCallback(async () => { const { workspaceAPI } = await import('@/infrastructure/api'); return workspaceAPI.getFileMetadata(filePath); @@ -2154,8 +2146,6 @@ const CodeEditor: React.FC = ({ selectedLines={selection.lines} language={detectedLanguage} encoding={encoding} - tabSize={editorConfig.tab_size || 2} - insertSpaces={editorConfig.insert_spaces !== false} isReadOnly={readOnly} lspStatus={ enableLsp && lspExtensionRegistry.isFileSupported(filePath) @@ -2163,7 +2153,6 @@ const CodeEditor: React.FC = ({ : undefined } onPositionClick={(e) => openStatusBarPopover('position', e)} - onIndentClick={(e) => openStatusBarPopover('indent', e)} onEncodingClick={(e) => openStatusBarPopover('encoding', e)} onLanguageClick={(e) => openStatusBarPopover('language', e)} /> @@ -2177,15 +2166,6 @@ const CodeEditor: React.FC = ({ onClose={closeStatusBarPopover} /> )} - {statusBarPopover === 'indent' && statusBarAnchorRect && ( - - )} {statusBarPopover === 'encoding' && statusBarAnchorRect && ( = ({ const parts = relativePath.split('/').filter(Boolean); if (parts.length === 0) return []; + const isUnderWorkspace = relativePath !== normalizedPath; + + log.debug('Building breadcrumb segments', { + filePath, + workspacePath, + normalizedPath, + normalizedWorkspace, + relativePath, + isUnderWorkspace, + }); + const result: PathSegment[] = []; - - // Add root directory as first level - if (normalizedWorkspace) { - const rootName = normalizedWorkspace.split('/').filter(Boolean).pop() || 'root'; - result.push({ - name: rootName, - fullPath: normalizedWorkspace, - isFile: false, - }); - } - let currentPath = normalizedWorkspace; + // File under workspace: show workspace root then relative parts + if (isUnderWorkspace) { + if (normalizedWorkspace) { + const rootName = normalizedWorkspace.split('/').filter(Boolean).pop() || 'root'; + result.push({ + name: rootName, + fullPath: normalizedWorkspace, + isFile: false, + }); + } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - currentPath = currentPath ? `${currentPath}/${part}` : part; - result.push({ - name: part, - fullPath: currentPath, - isFile: i === parts.length - 1, - }); + let currentPath = normalizedWorkspace || ''; + for (let i = 0; i < parts.length; i++) { + currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + result.push({ + name: parts[i], + fullPath: currentPath, + isFile: i === parts.length - 1, + }); + } + } else { + // Absolute path outside workspace: rebuild fullPath from normalizedPath + // preserving leading root (e.g. '/' for Unix, '' for Windows drive letters) + const hasLeadingSlash = normalizedPath.startsWith('/'); + let currentPath = hasLeadingSlash ? '/' : ''; + for (let i = 0; i < parts.length; i++) { + currentPath = currentPath === '/' ? `/${parts[i]}` : currentPath ? `${currentPath}/${parts[i]}` : parts[i]; + result.push({ + name: parts[i], + fullPath: currentPath, + isFile: i === parts.length - 1, + }); + } } + log.debug('Breadcrumb segments result', { + result: result.map(s => ({ name: s.name, fullPath: s.fullPath, isFile: s.isFile })), + }); + return result; }, [filePath, workspacePath]); // Load directory contents const loadDirectoryContents = useCallback(async (dirPath: string) => { + log.debug('Loading directory contents', { dirPath }); setDropdownLoading(true); setCurrentDirPath(dirPath); try { @@ -311,9 +340,10 @@ export const EditorBreadcrumb: React.FC = ({ isDirectory: entry.isDirectory || false, })); + log.debug('Directory contents loaded', { dirPath, itemCount: items.length }); setDropdownItems(items); } catch (error) { - log.error('Failed to load directory', error); + log.error('Failed to load directory', { dirPath, error: String(error) }); setDropdownItems([]); } finally { setDropdownLoading(false); @@ -338,6 +368,13 @@ export const EditorBreadcrumb: React.FC = ({ ? segment.fullPath.substring(0, segment.fullPath.lastIndexOf('/')) : segment.fullPath; + log.debug('Breadcrumb segment clicked', { + segmentName: segment.name, + segmentFullPath: segment.fullPath, + isFile: segment.isFile, + resolvedDirPath: dirPath, + }); + setInitialDirPath(dirPath); loadDirectoryContents(dirPath); } @@ -345,6 +382,12 @@ export const EditorBreadcrumb: React.FC = ({ // Handle dropdown item selection const handleDropdownSelect = useCallback(async (item: FileItem) => { + log.debug('Breadcrumb dropdown item selected', { + name: item.name, + path: item.path, + isDirectory: item.isDirectory, + }); + if (item.isDirectory) { loadDirectoryContents(item.path); } else { diff --git a/src/web-ui/src/tools/editor/components/EditorStatusBar.tsx b/src/web-ui/src/tools/editor/components/EditorStatusBar.tsx index 507570438..82906d418 100644 --- a/src/web-ui/src/tools/editor/components/EditorStatusBar.tsx +++ b/src/web-ui/src/tools/editor/components/EditorStatusBar.tsx @@ -1,7 +1,7 @@ /** Status bar for cursor position, language, encoding, and LSP status. */ import React from 'react'; -import { +import { AlertCircle, Loader2, Zap @@ -23,10 +23,6 @@ export interface EditorStatusBarProps { language: string; /** File encoding */ encoding?: string; - /** Tab size */ - tabSize?: number; - /** Whether to use spaces instead of tabs */ - insertSpaces?: boolean; /** Whether file has unsaved changes (reserved for extension) */ hasChanges?: boolean; /** Whether file is being saved (reserved for extension) */ @@ -39,8 +35,6 @@ export interface EditorStatusBarProps { onLanguageClick?: (e: React.MouseEvent) => void; /** Encoding click callback */ onEncodingClick?: (e: React.MouseEvent) => void; - /** Indent click callback */ - onIndentClick?: (e: React.MouseEvent) => void; /** Position click callback */ onPositionClick?: (e: React.MouseEvent) => void; } @@ -96,21 +90,21 @@ const getLspStatusInfo = ( ) => { switch (status) { case 'connected': - return { - icon: , + return { + icon: , className: 'editor-status-bar__lsp--connected', title: t('editor.statusBar.lspConnected') }; case 'connecting': - return { - icon: , + return { + icon: , className: 'editor-status-bar__lsp--connecting', title: t('editor.statusBar.lspConnecting') }; case 'disconnected': default: - return { - icon: , + return { + icon: , className: 'editor-status-bar__lsp--disconnected', title: t('editor.statusBar.lspDisconnected') }; @@ -124,13 +118,10 @@ export const EditorStatusBar: React.FC = ({ selectedLines = 0, language, encoding = 'UTF-8', - tabSize = 2, - insertSpaces = true, isReadOnly = false, lspStatus, onLanguageClick, onEncodingClick, - onIndentClick, onPositionClick, }) => { const { t } = useI18n('tools'); @@ -159,7 +150,7 @@ export const EditorStatusBar: React.FC = ({
-
@@ -170,21 +161,8 @@ export const EditorStatusBar: React.FC = ({
-
- - -
- {insertSpaces ? t('editor.statusBar.indentSpaces', { n: tabSize }) : t('editor.statusBar.indentTab', { n: tabSize })} -
-
- -
- -
@@ -195,7 +173,7 @@ export const EditorStatusBar: React.FC = ({
-
@@ -206,7 +184,7 @@ export const EditorStatusBar: React.FC = ({ {lspStatus && ( <>
-
diff --git a/src/web-ui/src/tools/editor/services/EditorManager.ts b/src/web-ui/src/tools/editor/services/EditorManager.ts index 98ec47198..da80d6a81 100644 --- a/src/web-ui/src/tools/editor/services/EditorManager.ts +++ b/src/web-ui/src/tools/editor/services/EditorManager.ts @@ -13,6 +13,7 @@ import { import { globalEventBus } from '../../../infrastructure/event-bus'; import { getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; +import { storage } from '@/shared/utils/storageAdapter'; const log = createLogger('EditorManager'); @@ -416,7 +417,7 @@ export class EditorManager implements IEditorManager { private loadConfig(): void { try { - const savedConfig = localStorage.getItem('editor-config'); + const savedConfig = storage.getItem('editor-config'); if (savedConfig) { const parsed = JSON.parse(savedConfig); this.config = { ...DEFAULT_CONFIG, ...parsed }; @@ -428,7 +429,7 @@ export class EditorManager implements IEditorManager { private saveConfig(): void { try { - localStorage.setItem('editor-config', JSON.stringify(this.config)); + storage.setItem('editor-config', JSON.stringify(this.config)); } catch (error) { log.warn('Failed to save config', error); } diff --git a/src/web-ui/src/tools/lsp/services/LspConfigService.ts b/src/web-ui/src/tools/lsp/services/LspConfigService.ts index 87ff426a8..4d2dab163 100644 --- a/src/web-ui/src/tools/lsp/services/LspConfigService.ts +++ b/src/web-ui/src/tools/lsp/services/LspConfigService.ts @@ -2,6 +2,7 @@ * LSP config service (user-facing settings). */ +import { storage } from '@/shared/utils/storageAdapter'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('LspConfigService'); @@ -30,7 +31,7 @@ class LspConfigService { getSettings(): LspSettings { try { - const saved = localStorage.getItem(LSP_SETTINGS_KEY); + const saved = storage.getItem(LSP_SETTINGS_KEY); if (saved) { const parsed = JSON.parse(saved); return { ...DEFAULT_LSP_SETTINGS, ...parsed }; @@ -43,7 +44,7 @@ class LspConfigService { saveSettings(settings: LspSettings): void { try { - localStorage.setItem(LSP_SETTINGS_KEY, JSON.stringify(settings)); + storage.setItem(LSP_SETTINGS_KEY, JSON.stringify(settings)); } catch (error) { log.error('Failed to save settings', { error }); throw error; diff --git a/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx b/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx index 511d88fc5..d4c18a45e 100644 --- a/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx +++ b/src/web-ui/src/tools/terminal/components/ConnectedTerminal.tsx @@ -37,6 +37,7 @@ export interface ConnectedTerminalProps { onClose?: () => void; onTitleChange?: (title: string) => void; onExit?: (exitCode?: number) => void; + supportsCopyPaste?: boolean; } const ConnectedTerminal: React.FC = memo(({ @@ -49,6 +50,7 @@ const ConnectedTerminal: React.FC = memo(({ onClose, onTitleChange, onExit, + supportsCopyPaste = true, }) => { const terminalRef = useRef(null); const [title, setTitle] = useState(initialSession?.name || 'Terminal'); @@ -412,6 +414,7 @@ const ConnectedTerminal: React.FC = memo(({ onReady={handleTerminalReady} onPaste={handlePaste} preventShrinkBelowColsRef={preventShrinkBelowColsRef} + supportsCopyPaste={supportsCopyPaste} /> {showStatusBar && session && ( diff --git a/src/web-ui/src/tools/terminal/components/Terminal.tsx b/src/web-ui/src/tools/terminal/components/Terminal.tsx index 9b11e65f7..313fcfcae 100644 --- a/src/web-ui/src/tools/terminal/components/Terminal.tsx +++ b/src/web-ui/src/tools/terminal/components/Terminal.tsx @@ -130,6 +130,7 @@ export interface TerminalProps { * content. Set back to 0 (or leave unset) to restore normal resize behaviour. */ preventShrinkBelowColsRef?: React.MutableRefObject; + supportsCopyPaste?: boolean; } export interface TerminalRef { @@ -179,6 +180,7 @@ const Terminal = forwardRef(({ onReady, onPaste, preventShrinkBelowColsRef, + supportsCopyPaste = true, }, ref) => { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -684,6 +686,7 @@ const Terminal = forwardRef(({ className={`bitfun-terminal ${className}`} data-terminal-id={terminalId} data-session-id={sessionId} + data-supports-copy-paste={supportsCopyPaste?'true':'false'} >
{ outDir: '../../dist', // Empty the output directory emptyOutDir: true, + minify: false, } }; });