diff --git a/Cargo.lock b/Cargo.lock index ede08203e7..4d7c38b839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1691,6 +1691,7 @@ name = "bitfun-webdriver" version = "0.2.17" dependencies = [ "anyhow", + "async-trait", "axum", "base64 0.22.1", "block2 0.6.2", diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 536e8fe6d4..a33661360f 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -161,7 +161,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['feedback','git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['feedback', 'git', 'mcp', 'miniapp-market', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', diff --git a/src/apps/desktop/src/api/miniapp_market_api.rs b/src/apps/desktop/src/api/miniapp_market_api.rs index 147cdb9b49..51e173ab7b 100644 --- a/src/apps/desktop/src/api/miniapp_market_api.rs +++ b/src/apps/desktop/src/api/miniapp_market_api.rs @@ -23,6 +23,8 @@ use bitfun_services_integrations::miniapp_market::{ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; +#[cfg(target_env = "ohos")] +use std::sync::Arc; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager, State, WebviewWindow}; @@ -32,6 +34,20 @@ use tokio::sync::Mutex; const MARKET_UPLOAD_PROGRESS_EVENT: &str = "miniapp-market-upload-progress"; const MARKET_ACCOUNT_CHANGED_EVENT: &str = "miniapp-market-account-changed"; +async fn market_client() -> Result { + #[cfg(target_env = "ohos")] + { + let store = + Arc::new(crate::api::ohos::market_credentials::OhosMarketCredentialStore::new()); + return MarketClient::from_environment_with_credential_store(store) + .await + .map_err(market_error); + } + + #[cfg(not(target_env = "ohos"))] + MarketClient::from_environment().await.map_err(market_error) +} + #[derive(Debug, Clone)] struct PendingDesktopAuth { request: DesktopAuthPollRequest, @@ -170,9 +186,7 @@ pub struct MarketPackageInspection { pub async fn miniapp_market_browse( request: MarketBrowseRequest, ) -> Result, String> { - let client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let client = market_client().await?; client.browse(&request).await.map_err(market_error) } @@ -180,17 +194,13 @@ pub async fn miniapp_market_browse( pub async fn miniapp_market_get_listing( request: MarketSlugRequest, ) -> Result { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client.listing(&request.slug).await.map_err(market_error) } #[tauri::command] pub async fn miniapp_market_auth_start() -> Result { - let client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let client = market_client().await?; let started = client.start_desktop_auth().await.map_err(market_error)?; let mut pending = pending_desktop_auth().lock().await; let now = unix_now(); @@ -231,9 +241,7 @@ pub async fn miniapp_market_auth_poll( } pending }; - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; let response: DesktopAuthPollResponse = client .poll_desktop_auth(&pending.request) .await @@ -254,17 +262,13 @@ pub async fn miniapp_market_auth_poll( #[tauri::command] pub async fn miniapp_market_me() -> Result, String> { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client.me().await.map_err(market_error) } #[tauri::command] pub async fn miniapp_market_logout(app: AppHandle) -> Result<(), String> { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client.logout().await.map_err(market_error)?; emit_market_account_changed(&app, "signed-out"); Ok(()) @@ -277,9 +281,7 @@ pub async fn miniapp_market_set_rating( if request.value.is_some_and(|value| !(1..=5).contains(&value)) { return Err("Rating must be between 1 and 5.".to_string()); } - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client .set_rating(&request.slug, request.value) .await @@ -290,9 +292,7 @@ pub async fn miniapp_market_set_rating( pub async fn miniapp_market_set_favorite( request: MarketSetFavoriteRequest, ) -> Result { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client .set_favorite(&request.slug, request.enabled) .await @@ -301,9 +301,7 @@ pub async fn miniapp_market_set_favorite( #[tauri::command] pub async fn miniapp_market_list_submissions() -> Result, String> { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client.list_submissions().await.map_err(market_error) } @@ -311,9 +309,7 @@ pub async fn miniapp_market_list_submissions() -> Result, pub async fn miniapp_market_withdraw_submission( request: MarketSubmissionIdRequest, ) -> Result { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; client .withdraw_submission(&request.submission_id) .await @@ -397,9 +393,7 @@ pub async fn miniapp_market_install( state: State<'_, AppState>, request: MarketInstallRequest, ) -> Result { - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; let detail = client.listing(&request.slug).await.map_err(market_error)?; let release = find_release(&detail, request.release_number)?; validate_minimum_bitfun_version(&release.min_bitfun_version)?; @@ -588,7 +582,9 @@ pub async fn miniapp_market_capture_window( .outer_size() .map_err(|error| format!("Could not read the BitFun window size: {error}"))?; if size.width < 320 || size.height < 240 { - return Err("The BitFun window is too small to capture a review screenshot.".to_string()); + return Err( + "The BitFun window is too small to capture a review screenshot.".to_string(), + ); } let capture_dir = app @@ -649,7 +645,6 @@ pub async fn miniapp_market_capture_window( { Err("Unable to support the ohos".to_string()) } - } #[tauri::command] @@ -663,9 +658,7 @@ pub async fn miniapp_market_submit_installed( .get(&request.app_id) .await .map_err(|error| error.to_string())?; - let mut client = MarketClient::from_environment() - .await - .map_err(market_error)?; + let mut client = market_client().await?; let mut progress = |submission_id: Option<&str>, phase: &'static str, completed, total| { emit_upload_progress(&app, submission_id, phase, completed, total); }; diff --git a/src/apps/desktop/src/api/ohos/market_credentials.rs b/src/apps/desktop/src/api/ohos/market_credentials.rs new file mode 100644 index 0000000000..804d9c7722 --- /dev/null +++ b/src/apps/desktop/src/api/ohos/market_credentials.rs @@ -0,0 +1,113 @@ +#![cfg(target_env = "ohos")] + +use async_trait::async_trait; +use bitfun_services_integrations::miniapp_market::{ + MarketCredentialStore, StoredMarketCredentials, +}; +use serde::{Deserialize, Serialize}; + +const ARKTS_FUNCTION: &str = "market_secure_credentials"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum CredentialAction { + Load, + Store, + Clear, +} + +#[derive(Debug, Serialize)] +struct CredentialRequest<'a> { + action: CredentialAction, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +struct CredentialResponse { + status: String, + value: Option, + code: Option, +} + +#[derive(Debug)] +pub struct OhosMarketCredentialStore; + +impl OhosMarketCredentialStore { + pub fn new() -> Self { + Self + } + + async fn call(&self, request: CredentialRequest<'_>) -> Result { + let input = serde_json::to_string(&request) + .map_err(|error| format!("encode market credential request: {error}"))?; + let output = bitfun_core::util::call_arkts_string_function(ARKTS_FUNCTION, input) + .await + .map_err(|error| format!("call OpenHarmony market credential store: {error}"))?; + serde_json::from_str(&output) + .map_err(|error| format!("decode market credential response: {error}")) + } +} + +#[async_trait] +impl MarketCredentialStore for OhosMarketCredentialStore { + async fn load(&self) -> Result, String> { + let response = self + .call(CredentialRequest { + action: CredentialAction::Load, + value: None, + }) + .await?; + match response.status.as_str() { + "ok" => response + .value + .ok_or_else(|| "market credential response omitted its value".to_string()) + .and_then(|value| { + serde_json::from_str(&value) + .map_err(|error| format!("parse market credentials: {error}")) + }) + .map(Some), + "not_found" => Ok(None), + _ => Err(format!( + "OpenHarmony market credential load failed: code={}", + response.code.as_deref().unwrap_or("UNKNOWN") + )), + } + } + + async fn save(&self, credentials: &StoredMarketCredentials) -> Result<(), String> { + let value = serde_json::to_string(credentials) + .map_err(|error| format!("serialize market credentials: {error}"))?; + let response = self + .call(CredentialRequest { + action: CredentialAction::Store, + value: Some(&value), + }) + .await?; + if response.status == "ok" { + Ok(()) + } else { + Err(format!( + "OpenHarmony market credential store failed: code={}", + response.code.as_deref().unwrap_or("UNKNOWN") + )) + } + } + + async fn clear(&self) -> Result<(), String> { + let response = self + .call(CredentialRequest { + action: CredentialAction::Clear, + value: None, + }) + .await?; + if matches!(response.status.as_str(), "ok" | "not_found") { + Ok(()) + } else { + Err(format!( + "OpenHarmony market credential delete failed: code={}", + response.code.as_deref().unwrap_or("UNKNOWN") + )) + } + } +} diff --git a/src/apps/desktop/src/api/ohos/mod.rs b/src/apps/desktop/src/api/ohos/mod.rs index 4e09b5d408..07cfa94628 100644 --- a/src/apps/desktop/src/api/ohos/mod.rs +++ b/src/apps/desktop/src/api/ohos/mod.rs @@ -1,6 +1,8 @@ -pub mod ohos_file_system; -pub mod window; pub mod browser; #[cfg(target_env = "ohos")] pub mod feedback_credentials; +#[cfg(target_env = "ohos")] +pub mod market_credentials; +pub mod ohos_file_system; pub mod update; +pub mod window; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 9405ef9614..6f8ce82a1e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -34,6 +34,7 @@ pub mod startup_trace; #[cfg(not(target_env = "ohos"))] pub mod tray; mod webview_recovery; +mod webdriver_window_host; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; use bitfun_core::infrastructure::ai::AIClientFactory; @@ -1068,7 +1069,10 @@ pub async fn _run() { window_duration_ms ); let webdriver_started = Instant::now(); - bitfun_webdriver::maybe_start(app_handle.clone()); + bitfun_webdriver::maybe_start( + app_handle.clone(), + webdriver_window_host::create(app_handle.clone()), + ); startup_trace.record_elapsed_step( "native_setup", "maybe_start_webdriver", @@ -2523,7 +2527,7 @@ fn start_event_loop_with_transport( event_router: Arc, transport: Arc, ) { - tauri::async_time::spawn(async move { + tauri::async_runtime::spawn(async move { event_loop_driver(event_queue, event_router, |event| { let transport = transport.clone(); async move { diff --git a/src/apps/desktop/src/webdriver_window_host.rs b/src/apps/desktop/src/webdriver_window_host.rs new file mode 100644 index 0000000000..bd84000e59 --- /dev/null +++ b/src/apps/desktop/src/webdriver_window_host.rs @@ -0,0 +1,239 @@ +use std::sync::Arc; +#[cfg(not(target_env = "ohos"))] +use std::time::Duration; + +use async_trait::async_trait; +use bitfun_webdriver::{WebDriverWindowHost, WindowCapabilities, WindowRect}; +use tauri::AppHandle; +#[cfg(not(target_env = "ohos"))] +use tauri::{Manager, PhysicalPosition, PhysicalSize, Position, Size}; + +pub(crate) fn create(app: AppHandle) -> Arc { + Arc::new(DesktopWindowHost { app }) +} + +struct DesktopWindowHost { + app: AppHandle, +} + +impl DesktopWindowHost { + #[cfg(not(target_env = "ohos"))] + fn window(&self, label: &str) -> Result { + self.app + .get_webview_window(label) + .ok_or_else(|| format!("Window not found: {label}")) + } + + #[cfg(target_env = "ohos")] + async fn call_ohos( + &self, + action: &str, + label: &str, + rect: Option, + ) -> Result { + let request = OhosWindowRequest { + action, + label, + rect, + }; + let input = serde_json::to_string(&request) + .map_err(|error| format!("Failed to encode HarmonyOS window request: {error}"))?; + let output = + bitfun_core::util::call_arkts_string_function("webdriver_window_ohos", input).await?; + let response: OhosWindowResponse = serde_json::from_str(&output) + .map_err(|error| format!("Invalid HarmonyOS window response: {error}: {output}"))?; + if response.ok { + Ok(response) + } else { + Err(response + .error + .unwrap_or_else(|| "HarmonyOS window operation failed".to_string())) + } + } +} + +#[cfg(target_env = "ohos")] +#[derive(serde::Serialize)] +struct OhosWindowRequest<'a> { + action: &'a str, + label: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + rect: Option, +} + +#[cfg(target_env = "ohos")] +#[derive(serde::Deserialize)] +struct OhosWindowResponse { + ok: bool, + #[serde(default)] + result: Option, + #[serde(default)] + handles: Option>, + #[serde(default)] + error: Option, +} + +#[async_trait] +impl WebDriverWindowHost for DesktopWindowHost { + fn window_handles(&self) -> Vec { + #[cfg(not(target_env = "ohos"))] + { + self.app.webview_windows().keys().cloned().collect() + } + #[cfg(target_env = "ohos")] + { + vec!["main".to_string()] + } + } + + fn capabilities(&self) -> WindowCapabilities { + WindowCapabilities { + set_window_rect: true, + } + } + + async fn get_rect(&self, label: &str) -> Result { + #[cfg(not(target_env = "ohos"))] + { + let window = self.window(label)?; + let position = window + .outer_position() + .map_err(|error| format!("Failed to read window position: {error}"))?; + let size = window + .outer_size() + .map_err(|error| format!("Failed to read window size: {error}"))?; + Ok(WindowRect { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }) + } + #[cfg(target_env = "ohos")] + { + self.call_ohos("getRect", label, None) + .await? + .result + .ok_or_else(|| "HarmonyOS getRect response is missing result".to_string()) + } + } + + async fn set_rect(&self, label: &str, rect: WindowRect) -> Result { + #[cfg(not(target_env = "ohos"))] + { + let window = self.window(label)?; + if window.is_fullscreen().unwrap_or(false) { + window + .set_fullscreen(false) + .map_err(|error| format!("Failed to leave fullscreen: {error}"))?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + if window.is_maximized().unwrap_or(false) { + window + .unmaximize() + .map_err(|error| format!("Failed to restore window: {error}"))?; + tokio::time::sleep(Duration::from_millis(50)).await; + } + + window + .set_position(Position::Physical(PhysicalPosition::new(rect.x, rect.y))) + .map_err(|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()) { + ( + outer.width.saturating_sub(inner.width), + outer.height.saturating_sub(inner.height), + ) + } else { + (0, 0) + }; + window + .set_size(Size::Physical(PhysicalSize::new( + rect.width.saturating_sub(chrome_width), + rect.height.saturating_sub(chrome_height), + ))) + .map_err(|error| format!("Failed to set window size: {error}"))?; + self.get_rect(label).await + } + #[cfg(target_env = "ohos")] + { + self.call_ohos("setRect", label, Some(rect)) + .await? + .result + .ok_or_else(|| "HarmonyOS setRect response is missing result".to_string()) + } + } + + async fn maximize(&self, label: &str) -> Result { + #[cfg(not(target_env = "ohos"))] + { + self.window(label)? + .maximize() + .map_err(|error| format!("Failed to maximize window: {error}"))?; + tokio::time::sleep(Duration::from_millis(100)).await; + self.get_rect(label).await + } + #[cfg(target_env = "ohos")] + { + self.call_ohos("maximize", label, None) + .await? + .result + .ok_or_else(|| "HarmonyOS maximize response is missing result".to_string()) + } + } + + async fn minimize(&self, label: &str) -> Result<(), String> { + #[cfg(not(target_env = "ohos"))] + { + self.window(label)? + .minimize() + .map_err(|error| format!("Failed to minimize window: {error}")) + } + #[cfg(target_env = "ohos")] + { + self.call_ohos("minimize", label, None).await?; + Ok(()) + } + } + + async fn fullscreen(&self, label: &str) -> Result { + #[cfg(not(target_env = "ohos"))] + { + self.window(label)? + .set_fullscreen(true) + .map_err(|error| format!("Failed to fullscreen window: {error}"))?; + tokio::time::sleep(Duration::from_millis(100)).await; + self.get_rect(label).await + } + #[cfg(target_env = "ohos")] + { + self.call_ohos("fullscreen", label, None) + .await? + .result + .ok_or_else(|| "HarmonyOS fullscreen response is missing result".to_string()) + } + } + + async fn close(&self, label: &str) -> Result, String> { + #[cfg(not(target_env = "ohos"))] + { + self.window(label)? + .destroy() + .map_err(|error| format!("Failed to close window: {error}"))?; + Ok(self + .window_handles() + .into_iter() + .filter(|handle| handle != label) + .collect()) + } + #[cfg(target_env = "ohos")] + { + Ok(self + .call_ohos("close", label, None) + .await? + .handles + .unwrap_or_default()) + } + } +} diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index d2de519a23..bb1da22b5d 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -20,6 +20,8 @@ import { CommonUtils } from '../utils/CommonUtils'; import { runDeveco } from '../utils/DevecoStart'; import { VoiceInputService } from '../services/VoiceInputService'; import { FeedbackCredentialStore } from '../utils/FeedbackCredentialStore'; +import { MarketCredentialStore } from '../utils/MarketCredentialStore'; +import { WindowHostRequest, WindowHostService } from '../services/WindowHostService'; import { BrowserWebviewService, CreateRequest, @@ -100,10 +102,35 @@ export default class EntryAbility extends RustAbility { const DOMAIN_NUMBER: number = 0xFF00; const TAG: string = '[BitfunUIAbilityComponents]'; const feedbackCredentialStore = new FeedbackCredentialStore(); + const marketCredentialStore = new MarketCredentialStore(); + const windowHostService = new WindowHostService(windowStage, () => { + this.context.terminateSelf(() => { + hilog.info(DOMAIN_NUMBER, TAG, 'WebDriver requested Ability termination'); + }); + }); + RustModule.registerArktsFunction('webdriver_window_ohos', async ( + _err: Error, + arg: string, + ): Promise => { + try { + const request: WindowHostRequest = JSON.parse(arg) as WindowHostRequest; + return await windowHostService.handle(request); + } catch (error) { + const businessError: BusinessError = error as BusinessError; + return JSON.stringify({ + ok: false, + error: businessError.message ?? 'Invalid WebDriver window request', + }); + } + }); RustModule.registerArktsFunction('feedback_secure_credentials', async ( _err: Error, arg: string ): Promise => feedbackCredentialStore.handle(arg)); + RustModule.registerArktsFunction('market_secure_credentials', async ( + _err: Error, + arg: string + ): Promise => marketCredentialStore.handle(arg)); RustModule.registerArktsFunction('open_dialog_file', async (err: Error, arg: string): Promise => { let res = await CommonUtils.open_file_dialog(arg); return res; diff --git a/src/apps/ohos/entry/src/main/ets/services/WindowHostService.ets b/src/apps/ohos/entry/src/main/ets/services/WindowHostService.ets new file mode 100644 index 0000000000..d7c647aa9f --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/services/WindowHostService.ets @@ -0,0 +1,165 @@ +import { window } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; + +const MAIN_WINDOW_LABEL: string = 'main'; +const FLOATING_TIMEOUT_MS: number = 2000; + +export interface WindowRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface WindowHostRequest { + action: string; + label: string; + rect?: WindowRect; +} + +interface WindowHostResponse { + ok: boolean; + result?: WindowRect; + handles?: string[]; + error?: string; +} + +/** + * Application-window provider for the embedded WebDriver server. + * + * This service deliberately controls only the HarmonyOS main WindowStage + * window. Embedded ArkUI Web nodes remain page/WebView resources and are not + * exposed as top-level WebDriver window handles. + */ +export class WindowHostService { + private readonly windowStage: window.WindowStage; + private readonly terminateAbility: () => void; + + constructor(windowStage: window.WindowStage, terminateAbility: () => void) { + this.windowStage = windowStage; + this.terminateAbility = terminateAbility; + } + + async handle(request: WindowHostRequest): Promise { + try { + this.validateLabel(request.label); + const mainWindow: window.Window = this.windowStage.getMainWindowSync(); + let response: WindowHostResponse; + + switch (request.action) { + case 'getRect': + response = { ok: true, result: this.readRect(mainWindow) }; + break; + case 'setRect': + if (request.rect === undefined || request.rect.width <= 0 || request.rect.height <= 0) { + throw new Error('setRect requires a positive width and height'); + } + await this.ensureFloating(mainWindow); + await mainWindow.resizeAsync(request.rect.width, request.rect.height); + await mainWindow.moveWindowToGlobalDisplay(request.rect.x, request.rect.y); + response = { ok: true, result: this.readRect(mainWindow) }; + break; + case 'maximize': + await mainWindow.maximize(window.MaximizePresentation.EXIT_IMMERSIVE); + response = { ok: true, result: this.readRect(mainWindow) }; + break; + case 'minimize': + await mainWindow.minimize(); + response = { ok: true }; + break; + case 'fullscreen': + await mainWindow.maximize(window.MaximizePresentation.ENTER_IMMERSIVE_DISABLE_TITLE_AND_DOCK_HOVER); + response = { ok: true, result: this.readRect(mainWindow) }; + break; + case 'close': + // Let the WebDriver HTTP response leave the process before Ability + // termination tears down the embedded server. + setTimeout(() => this.terminateAbility(), 100); + response = { ok: true, handles: [] }; + break; + default: + throw new Error('Unsupported window action: ' + request.action); + } + + return JSON.stringify(response); + } catch (error) { + return JSON.stringify({ ok: false, error: this.describeError(error) } as WindowHostResponse); + } + } + + private validateLabel(label: string): void { + if (label !== MAIN_WINDOW_LABEL) { + throw new Error('Unknown HarmonyOS window handle: ' + label); + } + } + + private readRect(mainWindow: window.Window): WindowRect { + const properties: window.WindowProperties = mainWindow.getWindowProperties(); + const rect: window.Rect = properties.globalDisplayRect ?? mainWindow.getGlobalRect(); + return { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + } + + private async ensureFloating(mainWindow: window.Window): Promise { + if (mainWindow.getWindowStatus() === window.WindowStatusType.MINIMIZE) { + await mainWindow.restore(); + } + if (mainWindow.getWindowStatus() === window.WindowStatusType.FLOATING) { + return; + } + + await mainWindow.recover(); + if (mainWindow.getWindowStatus() !== window.WindowStatusType.FLOATING) { + await this.waitForStatus(mainWindow, window.WindowStatusType.FLOATING); + } + } + + private waitForStatus( + mainWindow: window.Window, + expected: window.WindowStatusType, + ): Promise { + return new Promise((resolve, reject) => { + let completed: boolean = false; + const finish = (error?: Error): void => { + if (completed) { + return; + } + completed = true; + mainWindow.off('windowStatusDidChange', listener); + if (error === undefined) { + resolve(); + } else { + reject(error); + } + }; + const listener = (status: window.WindowStatusType): void => { + if (status === expected) { + finish(); + } + }; + mainWindow.on('windowStatusDidChange', listener); + if (mainWindow.getWindowStatus() === expected) { + finish(); + return; + } + setTimeout(() => { + if (mainWindow.getWindowStatus() === expected) { + finish(); + } else { + finish(new Error('Timed out waiting for HarmonyOS window status change')); + } + }, FLOATING_TIMEOUT_MS); + }); + } + + private describeError(error: Object): string { + const businessError: BusinessError = error as BusinessError; + const code: number = businessError.code; + const message: string = businessError.message ?? String(error); + return code === undefined ? message : 'HarmonyOS error ' + code + ': ' + message; + } +} diff --git a/src/apps/ohos/entry/src/main/ets/utils/MarketCredentialStore.ets b/src/apps/ohos/entry/src/main/ets/utils/MarketCredentialStore.ets new file mode 100644 index 0000000000..5e7359c743 --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/MarketCredentialStore.ets @@ -0,0 +1,127 @@ +import { util } from '@kit.ArkTS'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { asset } from '@kit.AssetStoreKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const MARKET_ASSET_ALIAS = 'bitfun.market.credentials.v1'; +const ASSET_NOT_FOUND = 24000002; +const ASSET_ALREADY_EXISTS = 24000003; +const LOG_DOMAIN = 0x0000; +const LOG_TAG = 'MarketCredentialStore'; + +interface CredentialRequest { + action: string; + value?: string; +} + +interface CredentialResponse { + status: string; + value?: string; + code?: string; +} + +export class MarketCredentialStore { + private readonly encoder = new util.TextEncoder(); + private readonly decoder = util.TextDecoder.create('utf-8'); + private readonly alias = this.encoder.encodeInto(MARKET_ASSET_ALIAS); + + async handle(input: string): Promise { + let action = 'parse'; + try { + const request: CredentialRequest = JSON.parse(input); + action = request.action; + if (request.action === 'load') { + return JSON.stringify(await this.load()); + } + if (request.action === 'store' && request.value !== undefined) { + await this.store(request.value); + return JSON.stringify({ status: 'ok' } as CredentialResponse); + } + if (request.action === 'clear') { + return JSON.stringify(await this.clear()); + } + return JSON.stringify({ status: 'error', code: 'REQUEST_INVALID' } as CredentialResponse); + } catch (error) { + const businessError = error as BusinessError; + const code = businessError.code === undefined ? 'INTERNAL_ERROR' : String(businessError.code); + hilog.error( + LOG_DOMAIN, + LOG_TAG, + 'Secure market credential operation failed: action=%{public}s code=%{public}s', + action, + code + ); + return JSON.stringify({ status: 'error', code } as CredentialResponse); + } + } + + private async load(): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, this.alias); + query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL); + try { + const results = await asset.query(query); + if (results.length === 0) { + return { status: 'not_found' }; + } + const secret = results[0].get(asset.Tag.SECRET) as Uint8Array; + if (secret === undefined) { + return { status: 'error', code: 'SECRET_MISSING' }; + } + return { status: 'ok', value: this.decoder.decodeToString(secret) }; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code === ASSET_NOT_FOUND) { + return { status: 'not_found' }; + } + throw error; + } + } + + private async store(value: string): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, this.alias); + const update: asset.AssetMap = new Map(); + update.set(asset.Tag.SECRET, this.encoder.encodeInto(value)); + try { + await asset.update(query, update); + return; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code !== ASSET_NOT_FOUND) { + throw error; + } + } + + const attributes: asset.AssetMap = new Map(); + attributes.set(asset.Tag.ALIAS, this.alias); + attributes.set(asset.Tag.SECRET, this.encoder.encodeInto(value)); + attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED); + attributes.set(asset.Tag.AUTH_TYPE, asset.AuthType.NONE); + attributes.set(asset.Tag.SYNC_TYPE, asset.SyncType.NEVER); + try { + await asset.add(attributes); + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code !== ASSET_ALREADY_EXISTS) { + throw error; + } + await asset.update(query, update); + } + } + + private async clear(): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, this.alias); + try { + await asset.remove(query); + return { status: 'ok' }; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code === ASSET_NOT_FOUND) { + return { status: 'not_found' }; + } + throw error; + } + } +} diff --git a/src/crates/adapters/webdriver/Cargo.toml b/src/crates/adapters/webdriver/Cargo.toml index ae93483d95..d7a882429e 100644 --- a/src/crates/adapters/webdriver/Cargo.toml +++ b/src/crates/adapters/webdriver/Cargo.toml @@ -11,6 +11,7 @@ embedded = [] [dependencies] anyhow = { workspace = true } +async-trait = { workspace = true } axum = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } serde = { workspace = true } diff --git a/src/crates/adapters/webdriver/src/executor/mod.rs b/src/crates/adapters/webdriver/src/executor/mod.rs index b20edb8179..0e286bc076 100644 --- a/src/crates/adapters/webdriver/src/executor/mod.rs +++ b/src/crates/adapters/webdriver/src/executor/mod.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use serde_json::Value; use tauri::{Manager, WebviewWindow}; -use crate::platform::{self, PrintOptions}; +#[cfg(not(target_env = "ohos"))] +use crate::platform; +use crate::platform::PrintOptions; use crate::runtime; use crate::server::response::WebDriverErrorResponse; use crate::server::AppState; @@ -50,15 +52,58 @@ impl BridgeExecutor { .map_err(map_bridge_error) } - pub async fn take_screenshot(&self) -> Result { - Err(WebDriverErrorResponse::no_such_window("No such windows")) + pub(crate) async fn take_screenshot(&self) -> Result { + #[cfg(not(target_env = "ohos"))] + { + 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 + } + + #[cfg(target_env = "ohos")] + { + Err(WebDriverErrorResponse::unsupported_operation( + "Taking screenshots is not supported on HarmonyOS", + )) + } } pub(crate) async fn print_page( &self, options: PrintOptions, ) -> Result { - Err(WebDriverErrorResponse::no_such_window("No such windows")) + #[cfg(not(target_env = "ohos"))] + { + 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 + } + + #[cfg(target_env = "ohos")] + { + let _ = options; + Err(WebDriverErrorResponse::unsupported_operation( + "Printing pages is not supported on HarmonyOS", + )) + } } pub(crate) fn webview_window(&self) -> Result { diff --git a/src/crates/adapters/webdriver/src/executor/window.rs b/src/crates/adapters/webdriver/src/executor/window.rs index c6bfc78c48..a1fa06bc64 100644 --- a/src/crates/adapters/webdriver/src/executor/window.rs +++ b/src/crates/adapters/webdriver/src/executor/window.rs @@ -1,141 +1,48 @@ -use std::time::Duration; - -use tauri::{PhysicalPosition, PhysicalSize, Position, Size}; - use crate::executor::BridgeExecutor; use crate::platform::WindowRect; use crate::server::response::WebDriverErrorResponse; impl BridgeExecutor { pub(crate) async fn get_window_rect(&self) -> Result { - #[cfg(not(target_env = "ohos"))] - { - let window = self.webview_window()?; - let position = window.outer_position().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!( - "Failed to read window position: {error}" - )) - })?; - let size = window.outer_size().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to read window size: {error}")) - })?; - - Ok(WindowRect { - x: position.x, - y: position.y, - width: size.width, - height: size.height, - }) - } - - #[cfg(target_env = "ohos")] - { - Err(WebDriverErrorResponse::unknown_error("Unsupported OS".to_owned())) - } - + self.state + .window_host + .get_rect(&self.session.current_window) + .await + .map_err(WebDriverErrorResponse::unknown_error) } pub(crate) async fn set_window_rect( &self, rect: WindowRect, ) -> Result { - #[cfg(not(target_env = "ohos"))] - { - 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()) { - ( - outer.width.saturating_sub(inner.width), - outer.height.saturating_sub(inner.height), - ) - } else { - (0, 0) - }; - - 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 - } - - #[cfg(target_env = "ohos")] - { - Err(WebDriverErrorResponse::unknown_error("Unsupported OS".to_owned())) - } + self.state + .window_host + .set_rect(&self.session.current_window, rect) + .await + .map_err(WebDriverErrorResponse::unknown_error) } pub(crate) async fn maximize_window(&self) -> Result { - #[cfg(not(target_env = "ohos"))] - { - 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 - } - - - #[cfg(target_env = "ohos")] - { - Err(WebDriverErrorResponse::unknown_error("Unsupported OS".to_owned())) - } + self.state + .window_host + .maximize(&self.session.current_window) + .await + .map_err(WebDriverErrorResponse::unknown_error) } pub(crate) async fn minimize_window(&self) -> Result<(), WebDriverErrorResponse> { - #[cfg(not(target_env = "ohos"))] - { - self.webview_window()?.minimize().map_err(|error| { - WebDriverErrorResponse::unknown_error(format!("Failed to minimize window: {error}")) - })?; - Ok(()) - } - - #[cfg(target_env = "ohos")] - { - Err(WebDriverErrorResponse::unknown_error("Unsupported OS".to_owned())) - } - + self.state + .window_host + .minimize(&self.session.current_window) + .await + .map_err(WebDriverErrorResponse::unknown_error) } pub(crate) async fn fullscreen_window(&self) -> Result { - #[cfg(not(target_env = "ohos"))] - { - 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 - } - #[cfg(target_env = "ohos")] - { - Err(WebDriverErrorResponse::unknown_error("Unsupported OS".to_owned())) - } + self.state + .window_host + .fullscreen(&self.session.current_window) + .await + .map_err(WebDriverErrorResponse::unknown_error) } } diff --git a/src/crates/adapters/webdriver/src/lib.rs b/src/crates/adapters/webdriver/src/lib.rs index dbfdc8664a..4ef5c2577f 100644 --- a/src/crates/adapters/webdriver/src/lib.rs +++ b/src/crates/adapters/webdriver/src/lib.rs @@ -12,11 +12,13 @@ use tauri::AppHandle; use server::AppState; +pub use platform::{WebDriverWindowHost, WindowCapabilities, WindowRect}; + const DEFAULT_WEBDRIVER_LABEL: &str = "main"; static SERVER_STARTED: AtomicBool = AtomicBool::new(false); -pub fn maybe_start(app: AppHandle) { +pub fn maybe_start(app: AppHandle, window_host: Arc) { if !(cfg!(debug_assertions) || cfg!(feature = "embedded")) { return; } @@ -34,7 +36,12 @@ pub fn maybe_start(app: AppHandle) { let preferred_label = std::env::var("BITFUN_WEBDRIVER_LABEL").unwrap_or_else(|_| DEFAULT_WEBDRIVER_LABEL.into()); - let state = Arc::new(AppState::new(app.clone(), preferred_label, port)); + let state = Arc::new(AppState::new( + app.clone(), + preferred_label, + port, + window_host, + )); runtime::register_listener(app, state.clone()); server::start(state); diff --git a/src/crates/adapters/webdriver/src/platform/mod.rs b/src/crates/adapters/webdriver/src/platform/mod.rs index cab1a3f3cd..f0654fa353 100644 --- a/src/crates/adapters/webdriver/src/platform/mod.rs +++ b/src/crates/adapters/webdriver/src/platform/mod.rs @@ -5,4 +5,7 @@ mod types; pub use capture::{print_page, take_screenshot}; pub use image::crop_screenshot; -pub use types::{Cookie, ElementScreenshotMetadata, PrintOptions, WindowRect}; +pub use types::{ + Cookie, ElementScreenshotMetadata, PrintOptions, WebDriverWindowHost, WindowCapabilities, + WindowRect, +}; diff --git a/src/crates/adapters/webdriver/src/platform/types.rs b/src/crates/adapters/webdriver/src/platform/types.rs index 14e3c5c594..45d55ea4ec 100644 --- a/src/crates/adapters/webdriver/src/platform/types.rs +++ b/src/crates/adapters/webdriver/src/platform/types.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use async_trait::async_trait; + #[derive(Debug, Clone, Default, Deserialize)] pub struct ElementScreenshotMetadata { pub x: f64, @@ -46,6 +48,36 @@ pub struct WindowRect { pub height: u32, } +#[derive(Debug, Clone, Copy, Default)] +pub struct WindowCapabilities { + pub set_window_rect: bool, +} + +/// Host-owned application-window operations used by the WebDriver protocol. +/// +/// Page JavaScript/navigation remains on the WebView bridge. Keeping native +/// application-window operations behind this port lets Tauri and HarmonyOS +/// WindowStage provide equivalent protocol behavior without leaking either +/// host API into the WebDriver adapter. +#[async_trait] +pub trait WebDriverWindowHost: Send + Sync { + fn window_handles(&self) -> Vec; + + fn capabilities(&self) -> WindowCapabilities; + + async fn get_rect(&self, label: &str) -> Result; + + async fn set_rect(&self, label: &str, rect: WindowRect) -> Result; + + async fn maximize(&self, label: &str) -> Result; + + async fn minimize(&self, label: &str) -> Result<(), String>; + + async fn fullscreen(&self, label: &str) -> Result; + + async fn close(&self, label: &str) -> Result, String>; +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Cookie { pub name: String, diff --git a/src/crates/adapters/webdriver/src/runtime/mod.rs b/src/crates/adapters/webdriver/src/runtime/mod.rs index 38c08e4753..1d54fd5a60 100644 --- a/src/crates/adapters/webdriver/src/runtime/mod.rs +++ b/src/crates/adapters/webdriver/src/runtime/mod.rs @@ -5,8 +5,10 @@ use serde::Deserialize; use serde_json::Value; use tauri::AppHandle; use tauri::Listener; +#[cfg(not(target_env = "ohos"))] use tauri::Manager; +#[cfg(not(target_env = "ohos"))] use crate::platform; use crate::server::response::WebDriverErrorResponse; use crate::server::AppState; @@ -83,7 +85,44 @@ pub(crate) 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 frame_context = script::serialize_frame_context(&session.frame_context); - Err(WebDriverErrorResponse::no_such_window("No such windows")) + + #[cfg(not(target_env = "ohos"))] + { + let webview = state + .app + .get_webview(&session.current_window) + .ok_or_else(|| { + WebDriverErrorResponse::no_such_window(format!( + "Webview not found: {}", + session.current_window + )) + })?; + + platform::evaluator::evaluate_script( + state, + webview, + timeout_ms, + script_source, + &args, + async_mode, + &frame_context, + ) + .await + } + + #[cfg(target_env = "ohos")] + { + let _ = ( + state, + timeout_ms, + script_source, + args, + async_mode, + frame_context, + ); + Err(WebDriverErrorResponse::unsupported_operation( + "Script execution is not supported on HarmonyOS", + )) + } } diff --git a/src/crates/adapters/webdriver/src/server/handlers/navigation.rs b/src/crates/adapters/webdriver/src/server/handlers/navigation.rs index e724054141..85d1c55f13 100644 --- a/src/crates/adapters/webdriver/src/server/handlers/navigation.rs +++ b/src/crates/adapters/webdriver/src/server/handlers/navigation.rs @@ -5,6 +5,7 @@ use axum::{ Json, }; use serde::Deserialize; +#[cfg(not(target_env = "ohos"))] use tauri::Manager; use super::{ensure_session, get_session}; @@ -22,7 +23,33 @@ pub async fn get_url( Path(session_id): Path, ) -> WebDriverResult { let session = get_session(&state, &session_id).await?; - Err(WebDriverErrorResponse::no_such_window("No such windows")) + + #[cfg(not(target_env = "ohos"))] + { + 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())) + } + + #[cfg(target_env = "ohos")] + { + let _ = session; + Err(WebDriverErrorResponse::unsupported_operation( + "Reading the current URL is not supported on HarmonyOS", + )) + } } pub async fn navigate( diff --git a/src/crates/adapters/webdriver/src/server/handlers/session.rs b/src/crates/adapters/webdriver/src/server/handlers/session.rs index 86a9dd7a35..1d3d614789 100644 --- a/src/crates/adapters/webdriver/src/server/handlers/session.rs +++ b/src/crates/adapters/webdriver/src/server/handlers/session.rs @@ -7,8 +7,10 @@ use axum::{ }; use serde::Deserialize; use serde_json::{json, Value}; +#[cfg(not(target_env = "ohos"))] use tauri::Manager; +#[cfg(not(target_env = "ohos"))] use crate::platform; use crate::server::response::{WebDriverErrorResponse, WebDriverResponse, WebDriverResult}; use crate::server::AppState; @@ -42,6 +44,7 @@ async fn wait_for_window( } } +#[cfg(not(target_env = "ohos"))] fn parse_user_agent(user_agent: &str) -> (String, String) { if user_agent.contains("Edg/") { let version = user_agent @@ -96,7 +99,34 @@ fn parse_user_agent(user_agent: &str) -> (String, String) { } async fn detect_browser_info(state: Arc, window_label: &str) -> (String, String) { - ("webview".to_string(), "unknown".to_string()) + #[cfg(not(target_env = "ohos"))] + { + 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()), + } + } + + #[cfg(target_env = "ohos")] + { + let _ = (state, window_label); + ("webview".to_string(), "unknown".to_string()) + } } pub async fn create( @@ -108,11 +138,7 @@ pub async fn create( let session = state.sessions.write().await.create(initial_window.clone()); - let set_window_rect = cfg!(any( - target_os = "macos", - target_os = "windows", - target_os = "linux" - )); + let set_window_rect = state.window_host.capabilities().set_window_rect; Ok(WebDriverResponse::success(json!({ "sessionId": session.id, diff --git a/src/crates/adapters/webdriver/src/server/handlers/window.rs b/src/crates/adapters/webdriver/src/server/handlers/window.rs index 355a5a9126..e1eecb00f2 100644 --- a/src/crates/adapters/webdriver/src/server/handlers/window.rs +++ b/src/crates/adapters/webdriver/src/server/handlers/window.rs @@ -6,7 +6,6 @@ use axum::{ }; use serde::Deserialize; use serde_json::json; -use tauri::Manager; use super::{ensure_session, get_session}; use crate::executor::BridgeExecutor; @@ -90,9 +89,17 @@ pub async fn close_window( ) -> WebDriverResult { let current_window = get_session(&state, &session_id).await?.current_window; - let handles = state.window_labels(); + let handles = state + .window_host + .close(¤t_window) + .await + .map_err(WebDriverErrorResponse::unknown_error)?; let next_handle = handles.first().cloned(); let mut sessions = state.sessions.write().await; + if handles.is_empty() { + sessions.delete(&session_id); + return Ok(WebDriverResponse::success(handles)); + } let session = sessions.get_mut(&session_id)?; if let Some(next_handle) = next_handle { session.current_window = next_handle; diff --git a/src/crates/adapters/webdriver/src/server/mod.rs b/src/crates/adapters/webdriver/src/server/mod.rs index 74a0650ab3..da4e27a49e 100644 --- a/src/crates/adapters/webdriver/src/server/mod.rs +++ b/src/crates/adapters/webdriver/src/server/mod.rs @@ -4,9 +4,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use tauri::AppHandle; -use tauri::Manager; use tokio::sync::{oneshot, RwLock}; +use crate::platform::WebDriverWindowHost; use crate::runtime::BridgeResponse; use crate::webdriver::SessionManager; @@ -17,6 +17,7 @@ pub mod router; pub struct AppState { pub app: AppHandle, pub preferred_label: String, + pub window_host: Arc, port: u16, pub sessions: RwLock, pub(crate) pending_requests: Mutex>>, @@ -24,10 +25,16 @@ pub struct AppState { } impl AppState { - pub fn new(app: AppHandle, preferred_label: String, port: u16) -> Self { + pub fn new( + app: AppHandle, + preferred_label: String, + port: u16, + window_host: Arc, + ) -> Self { Self { app, preferred_label, + window_host, port, sessions: RwLock::new(SessionManager::new()), pending_requests: Mutex::new(HashMap::new()), @@ -44,15 +51,22 @@ impl AppState { } pub fn initial_window_label(&self) -> Option { - self.app.webview_windows().keys().next().cloned() + let handles = self.window_labels(); + if handles.iter().any(|label| label == &self.preferred_label) { + Some(self.preferred_label.clone()) + } else { + handles.into_iter().next() + } } pub fn has_window(&self, label: &str) -> bool { - true + self.window_labels() + .iter() + .any(|candidate| candidate == label) } pub fn window_labels(&self) -> Vec { - self.app.webview_windows().keys().cloned().collect() + self.window_host.window_handles() } } diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index cfb540d906..61fa1cb774 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -505,6 +505,8 @@ pub struct TerminalConfig { pub default_shell: String, /// Terminal panel placement in the session layout: "right" or "bottom". pub terminal_panel_position: String, + /// Environment variables applied to terminals and agent command processes. + pub env_vars: HashMap, pub font_size: u32, pub font_family: String, pub cursor_blink: bool, @@ -1786,6 +1788,7 @@ impl Default for TerminalConfig { Self { default_shell: String::new(), terminal_panel_position: "right".to_string(), + env_vars: HashMap::new(), font_size: 14, font_family: "Consolas, \"Courier New\", monospace".to_string(), cursor_blink: true, @@ -2114,7 +2117,7 @@ mod tests { AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig, AgentProfileView, AppConfig, AppLoggingConfig, AuthConfig, GlobalConfig, MemoryExternalContextPolicy, ModelExchangeTracingMode, NotificationConfig, OpenCodePlan, - SubagentBatchExecutionPolicy, SubagentModelSelection, SubscriptionProvider, + SubagentBatchExecutionPolicy, SubagentModelSelection, SubscriptionProvider, TerminalConfig, UserSkillGroupsConfig, UserToolGroupsConfig, }; use bitfun_runtime_ports::ToolPermissionConfig; @@ -2128,6 +2131,29 @@ mod tests { assert!(!config.prevent_sleep); } + #[test] + fn terminal_environment_variables_survive_config_roundtrip() { + let config: TerminalConfig = serde_json::from_value(serde_json::json!({ + "env_vars": { + "BITFUN_TEST_TOKEN": "configured", + "EMPTY_VALUE": "" + } + })) + .expect("terminal config should deserialize"); + + assert_eq!( + config.env_vars.get("BITFUN_TEST_TOKEN").map(String::as_str), + Some("configured") + ); + assert_eq!( + serde_json::to_value(config).expect("terminal config should serialize")["env_vars"], + serde_json::json!({ + "BITFUN_TEST_TOKEN": "configured", + "EMPTY_VALUE": "" + }) + ); + } + #[test] fn subscription_auth_preserves_legacy_opencode_and_roundtrips_go_plan() { let legacy: AuthConfig = serde_json::from_value(serde_json::json!({ diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 0efb3779bf..f4f50c233e 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -208,6 +208,7 @@ miniapp-runtime = [ "which", ] miniapp-market = [ + "async-trait", "bitfun-product-domains/appearance-market", "chrono", "dep:apple-native-keyring-store", diff --git a/src/crates/services/services-integrations/src/miniapp_market/client.rs b/src/crates/services/services-integrations/src/miniapp_market/client.rs index deb17b9257..580987ea54 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/client.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/client.rs @@ -1,6 +1,5 @@ use super::credentials::{ - clear_market_credentials, load_market_credentials, save_market_credentials, - StoredMarketCredentials, + system_market_credential_store, MarketCredentialStore, StoredMarketCredentials, }; use bitfun_product_domains::miniapp::market::{ CursorPage, MarketListingDetail, MarketListingSummary, MarketSort, MarketSubmission, @@ -9,6 +8,7 @@ use bitfun_product_domains::miniapp::market::{ use reqwest::{Method, RequestBuilder, Response, StatusCode}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use std::sync::Arc; const DEFAULT_MARKET_API_URL: &str = "https://market.openbitfun.com/miniapp/api/v1"; @@ -117,16 +117,30 @@ pub struct MarketClient { base_url: String, client: reqwest::Client, credentials: Option, + credential_store: Arc, } impl MarketClient { pub async fn from_environment() -> Result { + Self::from_environment_with_credential_store(system_market_credential_store()).await + } + + pub async fn from_environment_with_credential_store( + credential_store: Arc, + ) -> Result { let base_url = std::env::var("BITFUN_MINIAPP_MARKET_API_URL") .unwrap_or_else(|_| DEFAULT_MARKET_API_URL.to_string()); - Self::new(base_url).await + Self::new_with_credential_store(base_url, credential_store).await } pub async fn new(base_url: impl Into) -> Result { + Self::new_with_credential_store(base_url, system_market_credential_store()).await + } + + pub async fn new_with_credential_store( + base_url: impl Into, + credential_store: Arc, + ) -> Result { let base_url = base_url.into().trim_end_matches('/').to_string(); let parsed = reqwest::Url::parse(&base_url) .map_err(|error| local_error("invalid_market_url", error.to_string()))?; @@ -145,13 +159,15 @@ impl MarketClient { .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| local_error("market_client_init_failed", error.to_string()))?; - let credentials = load_market_credentials() + let credentials = credential_store + .load() .await .map_err(|error| local_error("credential_store_unavailable", error))?; Ok(Self { base_url, client, credentials, + credential_store, }) } @@ -221,7 +237,8 @@ impl MarketClient { .await?; if let Some(tokens) = response.tokens.clone() { let credentials: StoredMarketCredentials = tokens.into(); - save_market_credentials(&credentials) + self.credential_store + .save(&credentials) .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = Some(credentials); @@ -245,7 +262,8 @@ impl MarketClient { .await .map_err(transport_error)?; if response.status() == StatusCode::UNAUTHORIZED { - clear_market_credentials() + self.credential_store + .clear() .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = None; @@ -398,7 +416,8 @@ impl MarketClient { return Err(response_error(response).await); } } - clear_market_credentials() + self.credential_store + .clear() .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = None; @@ -432,7 +451,8 @@ impl MarketClient { }; let now = chrono::Utc::now().timestamp(); if credentials.refresh_expires_at <= now { - clear_market_credentials() + self.credential_store + .clear() .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = None; @@ -450,7 +470,8 @@ impl MarketClient { .await .map_err(transport_error)?; if response.status() == StatusCode::UNAUTHORIZED { - clear_market_credentials() + self.credential_store + .clear() .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = None; @@ -458,7 +479,8 @@ impl MarketClient { } let tokens: MarketTokenPair = decode_json(checked_response(response).await?).await?; let stored: StoredMarketCredentials = tokens.into(); - save_market_credentials(&stored) + self.credential_store + .save(&stored) .await .map_err(|error| local_error("credential_store_unavailable", error))?; self.credentials = Some(stored); @@ -527,3 +549,52 @@ fn local_error(code: impl Into, message: impl Into) -> MarketCli request_id: None, } } + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug)] + struct EmptyCredentialStore { + load_count: AtomicUsize, + } + + #[async_trait] + impl MarketCredentialStore for EmptyCredentialStore { + async fn load(&self) -> Result, String> { + self.load_count.fetch_add(1, Ordering::Relaxed); + Ok(None) + } + + async fn save(&self, _credentials: &StoredMarketCredentials) -> Result<(), String> { + Ok(()) + } + + async fn clear(&self) -> Result<(), String> { + Ok(()) + } + } + + #[tokio::test] + async fn injected_credential_store_constructs_client_without_system_keyring() { + let store = Arc::new(EmptyCredentialStore { + load_count: AtomicUsize::new(0), + }); + + let client = MarketClient::new_with_credential_store( + "https://market.example.test/miniapp/api/v1/", + store.clone(), + ) + .await + .expect("an empty injected credential store should initialize the market client"); + + assert_eq!( + client.base_url, + "https://market.example.test/miniapp/api/v1" + ); + assert!(client.credentials.is_none()); + assert_eq!(store.load_count.load(Ordering::Relaxed), 1); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp_market/credentials.rs b/src/crates/services/services-integrations/src/miniapp_market/credentials.rs index 54309ebf1f..6548dd8230 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/credentials.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/credentials.rs @@ -1,5 +1,6 @@ +use async_trait::async_trait; use serde::{Deserialize, Serialize}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; const KEYRING_SERVICE: &str = "openbitfun.bitfun.miniapp-market.v1"; const KEYRING_ENTRY: &str = "github-oauth"; @@ -13,6 +14,47 @@ pub struct StoredMarketCredentials { pub refresh_expires_at: i64, } +#[async_trait] +pub trait MarketCredentialStore: std::fmt::Debug + Send + Sync { + async fn load(&self) -> Result, String>; + async fn save(&self, credentials: &StoredMarketCredentials) -> Result<(), String>; + async fn clear(&self) -> Result<(), String>; +} + +#[derive(Debug)] +pub struct SystemMarketCredentialStore; + +#[async_trait] +impl MarketCredentialStore for SystemMarketCredentialStore { + async fn load(&self) -> Result, String> { + load_system_market_credentials().await + } + + async fn save(&self, credentials: &StoredMarketCredentials) -> Result<(), String> { + save_system_market_credentials(credentials).await + } + + async fn clear(&self) -> Result<(), String> { + clear_system_market_credentials().await + } +} + +pub fn system_market_credential_store() -> Arc { + Arc::new(SystemMarketCredentialStore) +} + +pub async fn load_market_credentials() -> Result, String> { + load_system_market_credentials().await +} + +pub async fn save_market_credentials(credentials: &StoredMarketCredentials) -> Result<(), String> { + save_system_market_credentials(credentials).await +} + +pub async fn clear_market_credentials() -> Result<(), String> { + clear_system_market_credentials().await +} + fn keyring_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -48,7 +90,7 @@ fn open_entry() -> Result { .map_err(|error| format!("open market credential entry: {error}")) } -pub async fn load_market_credentials() -> Result, String> { +async fn load_system_market_credentials() -> Result, String> { tokio::task::spawn_blocking(move || { let _guard = keyring_lock() .lock() @@ -67,7 +109,9 @@ pub async fn load_market_credentials() -> Result .map_err(|error| format!("join market credential read: {error}"))? } -pub async fn save_market_credentials(credentials: &StoredMarketCredentials) -> Result<(), String> { +async fn save_system_market_credentials( + credentials: &StoredMarketCredentials, +) -> Result<(), String> { let secret = serde_json::to_vec(credentials) .map_err(|error| format!("serialize market credentials: {error}"))?; tokio::task::spawn_blocking(move || { @@ -82,7 +126,7 @@ pub async fn save_market_credentials(credentials: &StoredMarketCredentials) -> R .map_err(|error| format!("join market credential write: {error}"))? } -pub async fn clear_market_credentials() -> Result<(), String> { +async fn clear_system_market_credentials() -> Result<(), String> { tokio::task::spawn_blocking(move || { let _guard = keyring_lock() .lock() diff --git a/src/crates/services/services-integrations/src/miniapp_market/mod.rs b/src/crates/services/services-integrations/src/miniapp_market/mod.rs index 8ec280dc96..08dcc99aa5 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/mod.rs @@ -12,7 +12,8 @@ pub use client::{ }; pub use credentials::{ clear_market_credentials, load_market_credentials, save_market_credentials, - StoredMarketCredentials, + system_market_credential_store, MarketCredentialStore, StoredMarketCredentials, + SystemMarketCredentialStore, }; pub use package::{ build_market_package, validate_market_package, MarketPackageError, ValidatedMarketPackage, diff --git a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx index 02cbeb7c95..a65a004e85 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalMcpOverview.tsx @@ -469,7 +469,7 @@ const ExternalMcpOverview: React.FC = () => { setSettingsTab('mcp-tools')} + onClick={() => setSettingsTab('external-sources')} tooltip={t('external.manage')} aria-label={t('external.manage')} > diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 6120daec6d..a97d750245 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -411,17 +411,6 @@ "completed": "Completed", "failed": "Failed", "interrupted": "Interrupted" - }, - "menu": { - "closePet": "Close pet", - "closeBubble": "Close this bubble" - }, - "composer": { - "openTitle": "Send a message to this session", - "ariaLabel": "Send a message to this session", - "placeholder": "Type a message, Enter to send", - "cancel": "Cancel", - "send": "Send" } }, "session": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 9be20c459d..619475614c 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -411,18 +411,6 @@ "completed": "已完成", "failed": "执行失败", "interrupted": "已中断" - }, - "menu": { - "switchPet": "切换宠物", - "closePet": "关闭宠物", - "closeBubble": "关闭该气泡" - }, - "composer": { - "openTitle": "发送消息到该会话", - "ariaLabel": "发送消息到该会话", - "placeholder": "输入消息,回车发送", - "cancel": "取消", - "send": "发送" } }, "session": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index b21f048167..d1973cbdf9 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -411,18 +411,6 @@ "completed": "已完成", "failed": "執行失敗", "interrupted": "已中斷" - }, - "menu": { - "switchPet": "切換寵物", - "closePet": "關閉寵物", - "closeBubble": "關閉該氣泡" - }, - "composer": { - "openTitle": "傳送訊息到該工作階段", - "ariaLabel": "傳送訊息到該工作階段", - "placeholder": "輸入訊息,Enter 送出", - "cancel": "取消", - "send": "傳送" } }, "session": {