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/api/ohos/window.rs b/src/apps/desktop/src/api/ohos/window.rs index 8cc7e2c7a3..5b38d1a991 100644 --- a/src/apps/desktop/src/api/ohos/window.rs +++ b/src/apps/desktop/src/api/ohos/window.rs @@ -109,4 +109,289 @@ pub async fn window_is_maximized() -> Result { } Err(err) => Err(err.to_string()), } +} + +#[tauri::command] +pub async fn set_always_on_top_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_always_on_top_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_decorations_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_decorations_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_skip_taskbar_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_skip_taskbar_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_window_size_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_window_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_window_position_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_window_position_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn outer_position_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("outer_position_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn outer_size_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("outer_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn inner_size_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("inner_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn current_monitor_ohos() -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("current_monitor_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(result) => Ok(result), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn unmaximize_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("unmaximize_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_min_size_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_min_size_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_focus_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_focus_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn set_resizable_ohos(arg: String) -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("set_resizable_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok(arg)).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn maximize_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("maximize_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + Err(err) => Err(err.to_string()), + }, + Err(err) => Err(err.to_string()), + } +} + +#[tauri::command] +pub async fn center_ohos() -> Result<(), String> { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get("center_ohos").cloned() + }; + let Some(function) = function else { + return Err("The Arkts has not register the function".to_owned()); + }; + let res = function.call_async(Ok("".to_string())).await; + match res { + Ok(fut) => match fut.await { + Ok(_) => Ok(()), + 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/lib.rs b/src/apps/desktop/src/lib.rs index 9405ef9614..a4579884ee 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -59,8 +59,11 @@ 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, + center_ohos, close_window, current_monitor_ohos, handle_max_window, handle_min_window, + handle_restore_window, inner_size_ohos, maximize_ohos, outer_position_ohos, outer_size_ohos, + set_always_on_top_ohos, set_decorations_ohos, set_focus_ohos, set_min_size_ohos, + set_resizable_ohos, set_skip_taskbar_ohos, set_window_position_ohos, set_window_size_ohos, + unmaximize_ohos, window_is_maximized, window_is_minimized, window_start_dragging, }; use api::acp_client_api::*; use api::clipboard_file_api::*; @@ -342,7 +345,8 @@ fn persist_main_window_state(app: &tauri::AppHandle) -> Result<(), String> { #[cfg(target_env = "ohos")] { - Err("Unable to save main window state".to_string()) + let _ = app; + Ok(()) } } @@ -1974,6 +1978,21 @@ pub async fn _run() { close_window, set_theme_mode, open_external_ohos, + set_always_on_top_ohos, + set_decorations_ohos, + set_skip_taskbar_ohos, + set_window_size_ohos, + set_window_position_ohos, + outer_position_ohos, + outer_size_ohos, + inner_size_ohos, + current_monitor_ohos, + unmaximize_ohos, + set_min_size_ohos, + set_focus_ohos, + set_resizable_ohos, + maximize_ohos, + center_ohos, // Debug API (no-op stubs in release builds) api::debug_api::debug_devtools_available, @@ -2523,7 +2542,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/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index d2de519a23..96fa0adbbf 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -6,7 +6,7 @@ import { Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; -import { window } from '@kit.ArkUI'; +import { window, display } from '@kit.ArkUI'; import { RustAbility } from '@ohos-rs/ability'; import { CommonEventListener } from '../utils/CommonEventListener'; import { harmonyShare, systemShare } from '@kit.ShareKit'; @@ -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, @@ -32,6 +34,44 @@ import { const DOMAIN = 0x0000; +interface SetAlwaysOnTopArg { + isTopmost: boolean; +} +interface SetDecorationsArg { + visible: boolean; +} +interface SetSkipTaskbarArg { + skip: boolean; +} +interface SetWindowSizeArg { + width: number; + height: number; +} +interface SetWindowPositionArg { + x: number; + y: number; +} +interface SetMinSizeArg { + width: number; + height: number; +} +interface SetResizableArg { + resizable: boolean; +} +interface PointResult { + x: number; + y: number; +} +interface SizeResult { + width: number; + height: number; +} +interface MonitorResult { + width: number; + height: number; + scaleFactor: number; +} + export default class EntryAbility extends RustAbility { public moduleName: string = "bitfun_desktop_lib"; public defaultPage: boolean = true; @@ -100,10 +140,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; @@ -183,6 +248,134 @@ export default class EntryAbility extends RustAbility { windowStage.getMainWindowSync().startMoving(); return ''; }); + RustModule.registerArktsFunction('set_always_on_top_ohos', async (err: Error, arg: string): Promise => { + const req: SetAlwaysOnTopArg = JSON.parse(arg); + const mainWin = windowStage.getMainWindowSync(); + try { + await mainWin.setWindowTopmost(req.isTopmost); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `setWindowTopmost failed (code=${be.code}), falling back to showWindow: ${be.message}`); + try { + await mainWin.showWindow(); + } catch (e2) { + const be2 = e2 as BusinessError; + hilog.warn(DOMAIN, TAG, `showWindow fallback failed: ${be2.message}`); + } + } + return ''; + }); + RustModule.registerArktsFunction('set_decorations_ohos', async (err: Error, arg: string): Promise => { + const req: SetDecorationsArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().setWindowDecorVisible(req.visible); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `setWindowDecorVisible failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_skip_taskbar_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('set_window_size_ohos', async (err: Error, arg: string): Promise => { + const req: SetWindowSizeArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().resize(req.width, req.height); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `resize failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_window_position_ohos', async (err: Error, arg: string): Promise => { + const req: SetWindowPositionArg = JSON.parse(arg); + try { + await windowStage.getMainWindowSync().moveWindowTo(req.x, req.y); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `moveWindowTo failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('outer_position_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: PointResult = { x: rect.left, y: rect.top }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('outer_size_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: SizeResult = { width: rect.width, height: rect.height }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('inner_size_ohos', async (err: Error, arg: string): Promise => { + const rect = windowStage.getMainWindowSync().getWindowProperties().windowRect; + const result: SizeResult = { width: rect.width, height: rect.height }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('current_monitor_ohos', async (err: Error, arg: string): Promise => { + let dispWidth: number = 0; + let dispHeight: number = 0; + let scale: number = 1; + try { + const disp = display.getDefaultDisplaySync(); + dispWidth = disp.width; + dispHeight = disp.height; + scale = disp.densityPixels > 0 ? disp.densityPixels : 1; + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `getDefaultDisplaySync failed: ${be.message}`); + } + const result: MonitorResult = { width: dispWidth, height: dispHeight, scaleFactor: scale }; + return JSON.stringify(result); + }); + RustModule.registerArktsFunction('unmaximize_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().recover(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `recover failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_min_size_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('set_focus_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().showWindow(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `showWindow failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('set_resizable_ohos', async (err: Error, arg: string): Promise => { + return ''; + }); + RustModule.registerArktsFunction('maximize_ohos', async (err: Error, arg: string): Promise => { + try { + await windowStage.getMainWindowSync().maximize(); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `maximize failed: ${be.message}`); + } + return ''; + }); + RustModule.registerArktsFunction('center_ohos', async (err: Error, arg: string): Promise => { + try { + const mainWin = windowStage.getMainWindowSync(); + const rect = mainWin.getWindowProperties().windowRect; + const disp = display.getDefaultDisplaySync(); + const x: number = Math.max(0, Math.floor((disp.width - rect.width) / 2)); + const y: number = Math.max(0, Math.floor((disp.height - rect.height) / 2)); + await mainWin.moveWindowTo(x, y); + } catch (e) { + const be = e as BusinessError; + hilog.warn(DOMAIN, TAG, `center failed: ${be.message}`); + } + return ''; + }); RustModule.registerArktsFunction('call_calendar', async (err: Error, arg: string): Promise => { return await createMeetingEvent(arg); }); 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..02b10aabc4 --- /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..5e7ea2c03d --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/MarketCredentialStore.ets @@ -0,0 +1,136 @@ +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'; + +class MarketAssetOperationError extends Error { + readonly code: number; + + constructor(code: number) { + super('Market asset operation failed'); + this.code = code; + } +} + +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 new MarketAssetOperationError(businessError.code ?? -1); + } + } + + 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 new MarketAssetOperationError(businessError.code ?? -1); + } + } + + 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 new MarketAssetOperationError(businessError.code ?? -1); + } + 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 new MarketAssetOperationError(businessError.code ?? -1); + } + } +} diff --git a/src/apps/ohos/entry/src/main/module.json5 b/src/apps/ohos/entry/src/main/module.json5 index 819589a728..b78692bf95 100644 --- a/src/apps/ohos/entry/src/main/module.json5 +++ b/src/apps/ohos/entry/src/main/module.json5 @@ -131,6 +131,16 @@ ], "when": "always" } + }, + { + "name": "ohos.permission.WINDOW_TOPMOST", + "reason": "$string:permission_window_topmost_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "always" + } } ], "extensionAbilities": [ 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 index 3364b16c64..0bbb629f06 100644 --- a/src/apps/ohos/entry/src/main/resources/base/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/base/element/string.json @@ -54,6 +54,10 @@ { "name": "permission_microphone_reason", "value": "Used to capture microphone audio for voice input transcription in the chat composer" + }, + { + "name": "permission_window_topmost_reason", + "value": "Used to keep the floating toolbar window above other application windows" } ] } \ No newline at end of file diff --git a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json index b0a52c3c7f..047ab7eb92 100644 --- a/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -55,6 +55,10 @@ { "name": "permission_microphone_reason", "value": "用于采集麦克风音频以进行对话框语音输入转写" + }, + { + "name": "permission_window_topmost_reason", + "value": "用于将悬浮工具栏窗口保持在其他应用窗口之上" } ] } \ No newline at end of file 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/app/components/FeedbackDialog/FeedbackDialog.scss b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss index 21b9cff69a..8f324112f0 100644 --- a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss +++ b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.scss @@ -14,6 +14,14 @@ overflow: hidden; } +.bitfun-feedback__root { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; +} + .bitfun-feedback__center { display: flex; flex: 1; diff --git a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx index 9b01bb6581..dd04642e4b 100644 --- a/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx +++ b/src/web-ui/src/app/components/FeedbackDialog/FeedbackDialog.tsx @@ -317,7 +317,7 @@ export const FeedbackDialog: React.FC = ({ isOpen, onClose closeOnOverlayClick={!submitting && !replyState.sending} testId="feedback-dialog" > -
+
{completed ? (