diff --git a/docs/MobileDevelopment.md b/docs/MobileDevelopment.md new file mode 100644 index 00000000..fa752f8a --- /dev/null +++ b/docs/MobileDevelopment.md @@ -0,0 +1,164 @@ +# TauriTavern 移动端开发说明 + +本文档记录当前移动端(Android / iOS)开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并为后续替换官方修复留出清晰迁移路径。 + +## 1. Android WebView 安全区注入时机竞态 + +### 1.1 现象 + +- `#top-settings-holder` 偶发沉入状态栏。 +- 现象不稳定:同一版本在不同启动时机下表现不同。 +- 简单删除延时重试后,问题明显回归。 + +### 1.2 根因 + +根因不是 inset 数值计算本身,而是 **注入时机竞态**: + +- Android WebView 启动阶段常经历 `about:blank -> tauri.localhost` 的页面切换。 +- 若在 `about:blank` 或 `document.readyState=loading` 时注入 CSS 变量,后续导航会丢失变量。 +- 表层看是“safe area 失效”,本质是“注入到了错误上下文或过早上下文”。 + +参考问题: +https://github.com/tauri-apps/tauri/issues/14240 + +### 1.3 当前实现 + +核心在 `src-tauri/gen/android/app/src/main/java/com/tauritavern/app/MainActivity.kt`: + +- 保留 edge-to-edge 与透明系统栏配置(沉浸基础); +- 监听系统栏 inset; +- 将 inset 写入前端 CSS 变量 `--tt-safe-area-top/right/left`; +- 注入前先检查页面就绪: + - `document.readyState !== 'loading'` + - `location.href !== 'about:blank'` + - 未满足时进行有限次短重试。 + +前端消费变量在: + +- `src/style.css`(变量定义与 fallback) +- `src/css/mobile-styles.css`(顶部栏与容器定位使用变量) + +### 1.4 维护原则 + +- 不要把“就绪态判断”误删为一次性注入。 +- 不要把此问题误判为纯 CSS 问题;先验证变量是否被注入到正确页面上下文。 +- 若后续 Tauri 官方修复 WebView safe-area 注入时序,可再评估收敛逻辑。 + +--- + +## 2. Android 资源访问语义差异(APK assets) + +### 2.1 官方语义 + +Tauri 官方说明:Android 资源位于 APK assets,不是普通文件系统路径,返回值可能为 `asset://localhost/...`,需要通过 fs 插件语义访问。 +https://v2.tauri.app/develop/resources/#android + +### 2.2 过去的问题 + +- 模板文件读取失败(如 popup/template 相关异常)。 +- 默认内容索引读取失败(`default/content/index.json` not found)。 +- 直接按“普通路径”处理资源导致跨平台行为不一致。 + +### 2.3 架构改动(资源层收敛) + +#### A. 构建期生成资源索引与嵌入映射 + +`src-tauri/build.rs` 现在会: + +- 扫描 `../default/content` 和 `../src/scripts/templates`; +- 生成 `default_content_manifest.json`(默认内容清单); +- 生成 `embedded_resources.rs`(虚拟路径 -> `include_bytes!` 映射)。 + +#### B. 运行时统一资源访问入口 + +`src-tauri/src/infrastructure/assets.rs` 提供统一 API: + +- `read_resource_bytes` +- `read_resource_text` +- `read_resource_json` +- `copy_resource_to_file` +- `list_default_content_files_under` + +平台策略: + +- Android:优先走构建期嵌入资源映射; +- 非 Android:走 `BaseDirectory::Resource` + fs 访问。 + +#### C. 前后端模板读取解耦 + +- 后端新增命令:`read_frontend_template` + 文件:`src-tauri/src/presentation/commands/bridge.rs` +- 前端模板加载改为 Tauri 环境下优先 invoke: + 文件:`src/scripts/templates.js` + +#### D. 默认内容初始化改为“资源 -> 真实文件”复制流程 + +`src-tauri/src/infrastructure/repositories/file_content_repository.rs` 不再依赖资源目录的直接文件路径语义,改用统一资源接口复制到用户目录。 + +--- + +## 3. iOS / Android 应用数据目录解析异常 + +### 3.1 问题背景 + +在移动端,Tauri 提供的目录 API 在不同平台/版本可能与预期目录不一致。 +已确认 Android 存在已知问题:`appDataDir/localDataDir` 可能返回内部路径(如 `/data/user/0/...`)而非外部 app 目录(如 `/storage/emulated/0/Android/data/...`)。 + +### 3.2 当前方案:单点路径解析抽象 + +新增单点路径解析模块: +`src-tauri/src/infrastructure/paths.rs` + +统一入口: + +- `resolve_app_data_dir(app_handle)` + +当前行为: + +- Android:默认启用临时 workaround(从 `document_dir` 推导外部 app data 目录); +- 其他平台(含 iOS):回退到标准 `app_data_dir`。 + +### 3.3 架构收益 + +- 所有仓储与应用数据根路径都通过同一函数解析; +- 平台差异被收敛到一个模块,不向业务层扩散; +- 未来若 iOS 出现类似目录异常,可在同一模块增加 `cfg(target_os = "ios")` 分支,不需要修改各仓储。 + +--- + +## 4. 与上述问题相关的关键架构调整 + +### 4.1 基础设施层 + +- 新增 `infrastructure::assets`(资源读取/复制统一抽象) +- 新增 `infrastructure::paths`(应用数据目录统一抽象) +- `infrastructure::mod.rs` 导出上述模块 + +### 4.2 应用初始化与数据根目录 + +- `src-tauri/src/app.rs` 的 `resolve_data_root` / `resolve_log_root` 已改为依赖 `resolve_app_data_dir` + +### 4.3 资源协议访问权限 + +- `src-tauri/src/lib.rs` 在 setup 阶段对 `data_root` 执行: + - `asset_protocol_scope().allow_directory(&data_root, true)` +- 目的:允许 WebView 通过 asset 协议访问用户数据文件,避免前端资源加载 403。 + +### 4.4 前端接入点 + +- `src/scripts/templates.js`:模板读取在 Tauri 环境下走 `invoke('read_frontend_template')` +- `src/css/mobile-styles.css` + `src/style.css`:通过 `--tt-safe-area-*` 消费原生注入的安全区变量 + +--- + +## 5. 后续迁移与清理建议 + +1. **Tauri 官方修复目录 API 后** + 仅需调整 `infrastructure/paths.rs` 中 Android workaround 开关/分支,不要在仓储层做分散修补。 + +2. **Tauri 官方修复 WebView safe-area 注入后** + 可评估简化 `MainActivity` 的“页面就绪后注入”逻辑,但必须先验证不会回归 `about:blank` 时序竞态。 + +3. **新增移动端特性时** + 优先复用现有单点抽象(`assets.rs` / `paths.rs` / `MainActivity.kt`),避免再次把平台差异扩散到业务代码。 + diff --git a/docs/README.md b/docs/README.md index 4ff82f15..89258c0c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ 3. [前端指南](./FrontendGuide.md) - 前端代码结构、Tauri注入启动链与模块化路由开发指南 4. [后端结构](./BackendStructure.md) - 后端架构和模块说明 5. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑 +6. [移动端开发说明](./MobileDevelopment.md) - Android/iOS 关键坑位、资源访问与路径解析方案 ## 项目概述 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1b6eac0..7c68a0bc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2.5.5", features = [] } +serde_json = "1" [dependencies] tauri = { version = "2.10.2", features = ["protocol-asset"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6..88b08344 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,106 @@ +use std::error::Error; +use std::fs; +use std::path::{Path, PathBuf}; + fn main() { + println!("cargo:rerun-if-changed=../default/content"); + println!("cargo:rerun-if-changed=../src/scripts/templates"); + + if let Err(error) = generate_resource_artifacts() { + panic!("Failed to generate resource artifacts: {}", error); + } + tauri_build::build() } + +#[derive(Debug)] +struct ResourceEntry { + virtual_path: String, + source_path: PathBuf, +} + +fn generate_resource_artifacts() -> Result<(), Box> { + let content_root = PathBuf::from("../default/content"); + let template_root = PathBuf::from("../src/scripts/templates"); + let out_dir = PathBuf::from(std::env::var("OUT_DIR")?); + + let mut content_files = collect_relative_files(&content_root, &content_root)?; + content_files.sort(); + + fs::write( + out_dir.join("default_content_manifest.json"), + serde_json::to_string(&content_files)?, + )?; + + let mut embedded_resources = Vec::new(); + embedded_resources.extend( + content_files + .iter() + .map(|relative| ResourceEntry { + virtual_path: format!("default/content/{}", relative), + source_path: content_root.join(relative), + }) + .collect::>(), + ); + + let template_files = collect_relative_files(&template_root, &template_root)?; + embedded_resources.extend( + template_files + .iter() + .map(|relative| ResourceEntry { + virtual_path: format!("frontend-templates/{}", relative), + source_path: template_root.join(relative), + }) + .collect::>(), + ); + + embedded_resources.sort_by(|a, b| a.virtual_path.cmp(&b.virtual_path)); + + fs::write( + out_dir.join("embedded_resources.rs"), + build_embedded_resources_source(&embedded_resources)?, + )?; + + Ok(()) +} + +fn collect_relative_files(root: &Path, current: &Path) -> Result, Box> { + let mut files = Vec::new(); + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + + if file_type.is_dir() { + files.extend(collect_relative_files(root, &path)?); + } else if file_type.is_file() { + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + files.push(relative); + } + } + + Ok(files) +} + +fn build_embedded_resources_source(resources: &[ResourceEntry]) -> Result> { + let mut source = String::from("pub fn get_embedded_resource(path: &str) -> Option<&'static [u8]> {\n"); + source.push_str(" match path {\n"); + + for resource in resources { + let canonical = resource.source_path.canonicalize()?; + let include_path = canonical.to_string_lossy().replace('\\', "/"); + source.push_str(&format!( + " {:?} => Some(include_bytes!(r#\"{}\"#)),\n", + resource.virtual_path, include_path + )); + } + + source.push_str(" _ => None,\n"); + source.push_str(" }\n"); + source.push_str("}\n"); + + Ok(source) +} diff --git a/src-tauri/gen/android/app/src/main/java/com/tauritavern/app/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/com/tauritavern/app/MainActivity.kt index cfb4d434..11021ef5 100644 --- a/src-tauri/gen/android/app/src/main/java/com/tauritavern/app/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/com/tauritavern/app/MainActivity.kt @@ -1,11 +1,154 @@ package com.tauritavern.app +import android.content.res.Configuration +import android.graphics.Color +import android.os.Build import android.os.Bundle +import android.view.View +import android.view.WindowManager +import android.webkit.WebView import androidx.activity.enableEdgeToEdge +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import java.util.Locale class MainActivity : TauriActivity() { + private var webView: WebView? = null + private var systemBarInsets: Insets = Insets.NONE + override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + configureImmersiveSystemBars() + attachSystemInsetsListener() + requestSystemInsets() + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + configureImmersiveSystemBars() + refreshInsetsInjection() + } + + override fun onWebViewCreate(webView: WebView) { + this.webView = webView + refreshInsetsInjection() + } + + override fun onResume() { + super.onResume() + refreshInsetsInjection() + } + + @Suppress("DEPRECATION") + private fun configureImmersiveSystemBars() { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.attributes = window.attributes.apply { + layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isStatusBarContrastEnforced = false + window.isNavigationBarContrastEnforced = false + } + + val isDarkMode = + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES + + WindowInsetsControllerCompat(window, window.decorView).apply { + isAppearanceLightStatusBars = !isDarkMode + isAppearanceLightNavigationBars = !isDarkMode + } + } + + private fun attachSystemInsetsListener() { + val contentRoot = getContentRoot() ?: return + + ViewCompat.setOnApplyWindowInsetsListener(contentRoot) { _, insets -> + updateSystemBarInsets(insets) + insets + } + } + + private fun updateSystemBarInsets(insets: WindowInsetsCompat) { + val insetTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + val visibleInsets = insets.getInsets(insetTypes) + val stableInsets = insets.getInsetsIgnoringVisibility(insetTypes) + systemBarInsets = + Insets.of( + maxOf(visibleInsets.left, stableInsets.left), + maxOf(visibleInsets.top, stableInsets.top), + maxOf(visibleInsets.right, stableInsets.right), + maxOf(visibleInsets.bottom, stableInsets.bottom), + ) + pushInsetsToWebView() + } + + private fun requestSystemInsets() { + getContentRoot()?.let { ViewCompat.requestApplyInsets(it) } + } + + private fun getContentRoot(): View? = window.decorView.findViewById(android.R.id.content) + + private fun pushInsetsToWebView() { + val targetWebView = webView ?: return + val density = resources.displayMetrics.density + fun toCssPx(value: Int): String = + String.format(Locale.US, "%.2fpx", value / density) + + val script = + """ + (() => { + const root = document.documentElement; + if (!root) return; + root.style.setProperty('--tt-safe-area-top', '${toCssPx(systemBarInsets.top)}'); + root.style.setProperty('--tt-safe-area-right', '${toCssPx(systemBarInsets.right)}'); + root.style.setProperty('--tt-safe-area-left', '${toCssPx(systemBarInsets.left)}'); + })(); + """.trimIndent() + + targetWebView.post { + targetWebView.evaluateJavascript(script, null) + } + } + + private fun syncInsetsWhenPageReady(attempt: Int = 0) { + val targetWebView = webView ?: return + val maxAttempts = 100 + val retryDelayMs = 80L + + val readinessScript = + """ + (() => document.readyState !== 'loading' && location.href !== 'about:blank')(); + """.trimIndent() + + targetWebView.post { + targetWebView.evaluateJavascript( + readinessScript, + ) { value -> + if (value == "true") { + pushInsetsToWebView() + return@evaluateJavascript + } + if (attempt < maxAttempts) { + targetWebView.postDelayed({ syncInsetsWhenPageReady(attempt + 1) }, retryDelayMs) + } + } + } + } + + private fun refreshInsetsInjection() { + requestSystemInsets() + syncInsetsWhenPageReady() } } diff --git a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml index d1242653..12ee5e31 100644 --- a/src-tauri/gen/android/app/src/main/res/values-night/themes.xml +++ b/src-tauri/gen/android/app/src/main/res/values-night/themes.xml @@ -1,6 +1,11 @@ diff --git a/src-tauri/gen/android/app/src/main/res/values/themes.xml b/src-tauri/gen/android/app/src/main/res/values/themes.xml index d1242653..12ee5e31 100644 --- a/src-tauri/gen/android/app/src/main/res/values/themes.xml +++ b/src-tauri/gen/android/app/src/main/res/values/themes.xml @@ -1,6 +1,11 @@ diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 9dc81f27..7f64e0bb 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -20,6 +20,7 @@ use crate::application::services::user_directory_service::UserDirectoryService; use crate::application::services::user_service::UserService; use crate::application::services::world_info_service::WorldInfoService; use crate::domain::errors::DomainError; +use crate::infrastructure::paths::resolve_app_data_dir; mod bootstrap; @@ -90,12 +91,6 @@ pub fn resolve_log_root(app_handle: &AppHandle) -> Result Result> { - let app_data_dir = app_handle.path().app_data_dir()?; - tracing::info!("App data directory: {:?}", app_data_dir); - Ok(app_data_dir) -} - pub fn spawn_initialization(app_handle: AppHandle, data_root: PathBuf) { tauri::async_runtime::spawn(async move { match AppState::new(app_handle.clone(), &data_root).await { diff --git a/src-tauri/src/infrastructure/assets.rs b/src-tauri/src/infrastructure/assets.rs new file mode 100644 index 00000000..f4e327f8 --- /dev/null +++ b/src-tauri/src/infrastructure/assets.rs @@ -0,0 +1,179 @@ +use serde::de::DeserializeOwned; +use std::path::Path; +use std::sync::OnceLock; +#[cfg(not(target_os = "android"))] +use tauri::path::BaseDirectory; +use tauri::AppHandle; +#[cfg(not(target_os = "android"))] +use tauri::Manager; +#[cfg(not(target_os = "android"))] +use tauri_plugin_fs::FsExt; +use tokio::fs; + +use crate::domain::errors::DomainError; +use crate::infrastructure::logging::logger; + +static DEFAULT_CONTENT_MANIFEST: OnceLock> = OnceLock::new(); + +#[cfg(target_os = "android")] +mod embedded_resources { + include!(concat!(env!("OUT_DIR"), "/embedded_resources.rs")); +} + +pub fn read_resource_text(app_handle: &AppHandle, relative_path: &str) -> Result { + let bytes = read_resource_bytes(app_handle, relative_path)?; + String::from_utf8(bytes).map_err(|e| { + logger::error(&format!( + "Failed to decode resource text {:?}: {}", + relative_path, e + )); + DomainError::InvalidData(format!( + "Resource '{}' is not valid UTF-8: {}", + relative_path, e + )) + }) +} + +pub fn read_resource_bytes(app_handle: &AppHandle, relative_path: &str) -> Result, DomainError> { + let normalized = normalize_resource_relative_path(relative_path)?; + + #[cfg(target_os = "android")] + { + let _ = app_handle; + if let Some(bytes) = embedded_resources::get_embedded_resource(&normalized) { + return Ok(bytes.to_vec()); + } + + return Err(DomainError::NotFound(format!( + "Embedded resource not found: {}", + normalized + ))); + } + + #[cfg(not(target_os = "android"))] + { + let path = resolve_resource_path(app_handle, &normalized)?; + app_handle.fs().read(&path).map_err(|e| { + logger::error(&format!( + "Failed to read resource bytes {:?} (resolved to {:?}): {}", + normalized, path, e + )); + map_resource_error(&normalized, e) + }) + } +} + +pub fn read_resource_json( + app_handle: &AppHandle, + relative_path: &str, +) -> Result { + let text = read_resource_text(app_handle, relative_path)?; + serde_json::from_str(&text).map_err(|e| { + logger::error(&format!( + "Failed to parse JSON resource {:?}: {}", + relative_path, e + )); + DomainError::InvalidData(format!( + "Invalid JSON resource '{}': {}", + relative_path, e + )) + }) +} + +pub async fn copy_resource_to_file( + app_handle: &AppHandle, + resource_relative_path: &str, + destination: &Path, +) -> Result<(), DomainError> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).await.map_err(|e| { + logger::error(&format!( + "Failed to create destination directory {:?}: {}", + parent, e + )); + DomainError::InternalError(format!("Failed to create directory: {}", e)) + })?; + } + + let bytes = read_resource_bytes(app_handle, resource_relative_path)?; + fs::write(destination, bytes).await.map_err(|e| { + logger::error(&format!( + "Failed to write copied resource {:?} to {:?}: {}", + resource_relative_path, destination, e + )); + DomainError::InternalError(format!("Failed to write resource file: {}", e)) + }) +} + +pub fn list_default_content_files_under(prefix: &str) -> Vec { + let normalized_prefix = prefix.trim_matches('/').replace('\\', "/"); + let query = if normalized_prefix.is_empty() { + String::new() + } else { + format!("{}/", normalized_prefix) + }; + + let files = DEFAULT_CONTENT_MANIFEST.get_or_init(load_default_content_manifest); + if query.is_empty() { + return files.to_vec(); + } + + files + .iter() + .filter(|path| path.starts_with(&query)) + .cloned() + .collect() +} + +fn load_default_content_manifest() -> Vec { + let raw = include_str!(concat!(env!("OUT_DIR"), "/default_content_manifest.json")); + match serde_json::from_str::>(raw) { + Ok(mut entries) => { + entries.sort(); + entries + } + Err(error) => { + logger::error(&format!( + "Failed to load generated default content manifest: {}", + error + )); + Vec::new() + } + } +} + +#[cfg(not(target_os = "android"))] +fn resolve_resource_path(app_handle: &AppHandle, relative_path: &str) -> Result { + app_handle + .path() + .resolve(relative_path, BaseDirectory::Resource) + .map_err(|e| { + logger::error(&format!( + "Failed to resolve resource path {:?}: {}", + relative_path, e + )); + DomainError::InternalError(format!("Failed to resolve resource path: {}", e)) + }) +} + +fn normalize_resource_relative_path(relative_path: &str) -> Result { + let normalized = relative_path.trim().replace('\\', "/"); + let normalized = normalized.trim_start_matches('/').to_string(); + + if normalized.is_empty() || normalized.contains("..") { + return Err(DomainError::InvalidData(format!( + "Invalid resource path: {}", + relative_path + ))); + } + + Ok(normalized) +} + +fn map_resource_error(relative_path: &str, error: std::io::Error) -> DomainError { + if error.kind() == std::io::ErrorKind::NotFound { + DomainError::NotFound(format!("Resource not found: {}", relative_path)) + } else { + DomainError::InternalError(format!("Failed to read resource '{}': {}", relative_path, error)) + } +} diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index fd24b92c..52f84eab 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -1,5 +1,7 @@ // Infrastructure layer - implements interfaces defined in the domain layer pub mod apis; +pub mod assets; pub mod logging; +pub mod paths; pub mod persistence; pub mod repositories; diff --git a/src-tauri/src/infrastructure/paths.rs b/src-tauri/src/infrastructure/paths.rs new file mode 100644 index 00000000..66fe8f23 --- /dev/null +++ b/src-tauri/src/infrastructure/paths.rs @@ -0,0 +1,60 @@ +use std::error::Error; +use std::path::PathBuf; +#[cfg(target_os = "android")] +use std::path::Path; +use tauri::{AppHandle, Manager}; + +// Temporary workaround for Tauri Android app-data path bug. +// When upstream is fixed, flip this to `false` (or remove Android branch). +#[cfg(target_os = "android")] +const USE_ANDROID_EXTERNAL_APP_DATA_WORKAROUND: bool = true; + +pub fn resolve_app_data_dir(app_handle: &AppHandle) -> Result> { + #[cfg(target_os = "android")] + { + if USE_ANDROID_EXTERNAL_APP_DATA_WORKAROUND { + if let Ok(document_dir) = app_handle.path().document_dir() { + if let Some(path) = derive_android_external_app_data_dir(&document_dir) { + tracing::debug!( + "Using Android external app data directory workaround: {:?}", + path + ); + return Ok(path); + } + } + } + } + + Ok(app_handle.path().app_data_dir()?) +} + +#[cfg(target_os = "android")] +fn derive_android_external_app_data_dir(document_dir: &Path) -> Option { + let leaf = document_dir.file_name()?.to_str()?; + + let candidate = if leaf.eq_ignore_ascii_case("documents") { + let parent = document_dir.parent()?; + let parent_leaf = parent.file_name().and_then(|s| s.to_str()).unwrap_or(""); + if parent_leaf.eq_ignore_ascii_case("files") { + parent.parent()?.to_path_buf() + } else { + parent.to_path_buf() + } + } else if leaf.eq_ignore_ascii_case("files") { + document_dir.parent()?.to_path_buf() + } else { + return None; + }; + + if is_android_external_app_dir(&candidate) { + Some(candidate) + } else { + None + } +} + +#[cfg(target_os = "android")] +fn is_android_external_app_dir(path: &Path) -> bool { + let normalized = path.to_string_lossy().replace('\\', "/"); + normalized.contains("/Android/data/") +} diff --git a/src-tauri/src/infrastructure/repositories/file_avatar_repository.rs b/src-tauri/src/infrastructure/repositories/file_avatar_repository.rs index 258bc009..1ab9cbfc 100644 --- a/src-tauri/src/infrastructure/repositories/file_avatar_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_avatar_repository.rs @@ -2,12 +2,13 @@ use async_trait::async_trait; use image::ImageFormat; use std::fs; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager}; +use tauri::AppHandle; use tokio::fs as tokio_fs; use crate::domain::errors::DomainError; use crate::domain::models::avatar::{Avatar, AvatarUploadResult, CropInfo}; use crate::domain::repositories::avatar_repository::AvatarRepository; +use crate::infrastructure::paths::resolve_app_data_dir; // Constants for avatar dimensions const AVATAR_WIDTH: u32 = 400; @@ -22,10 +23,8 @@ impl FileAvatarRepository { /// Create a new FileAvatarRepository pub fn new(app_handle: AppHandle) -> Self { // Get app data directory - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); + let app_data_dir = + resolve_app_data_dir(&app_handle).expect("Failed to get app data directory"); // Construct avatars directory let avatars_dir = app_data_dir diff --git a/src-tauri/src/infrastructure/repositories/file_content_repository.rs b/src-tauri/src/infrastructure/repositories/file_content_repository.rs index 6d62dbcb..8833922b 100644 --- a/src-tauri/src/infrastructure/repositories/file_content_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_content_repository.rs @@ -1,17 +1,17 @@ use async_trait::async_trait; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use tauri::path::BaseDirectory; use tauri::AppHandle; -use tauri::Manager; use tokio::fs; use crate::domain::errors::DomainError; use crate::domain::repositories::content_repository::{ ContentItem, ContentRepository, ContentType, }; +use crate::infrastructure::assets::{ + copy_resource_to_file, list_default_content_files_under, read_resource_json, +}; use crate::infrastructure::logging::logger; -use crate::infrastructure::persistence::file_system::read_json_file; /// Content index item from JSON #[derive(Debug, Clone, Serialize, Deserialize)] @@ -94,72 +94,50 @@ impl FileContentRepository { } } - /// Copy a file from default content to user content - async fn copy_file(&self, source_path: &Path, dest_path: &Path) -> Result<(), DomainError> { - // Create parent directory if it doesn't exist - if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).await.map_err(|e| { - logger::error(&format!("Failed to create directory {:?}: {}", parent, e)); - DomainError::InternalError(format!("Failed to create directory: {}", e)) - })?; - } - - // Only copy if destination doesn't exist - if !dest_path.exists() { - logger::debug(&format!( - "Copying file from {:?} to {:?}", - source_path, dest_path - )); - - fs::copy(source_path, dest_path).await.map_err(|e| { - logger::error(&format!( - "Failed to copy file from {:?} to {:?}: {}", - source_path, dest_path, e - )); - DomainError::InternalError(format!("Failed to copy file: {}", e)) - })?; - } else { - logger::debug(&format!( - "Skipping copy, file already exists: {:?}", - dest_path - )); - } - - Ok(()) + fn is_directory_entry(path: &str) -> bool { + Path::new(path).extension().is_none() } - /// Copy a directory from default content to user content - async fn copy_directory(&self, source_dir: &Path, dest_dir: &Path) -> Result<(), DomainError> { - // Create destination directory if it doesn't exist - fs::create_dir_all(dest_dir).await.map_err(|e| { - logger::error(&format!("Failed to create directory {:?}: {}", dest_dir, e)); - DomainError::InternalError(format!("Failed to create directory: {}", e)) - })?; - - // Read source directory - let mut entries = fs::read_dir(source_dir).await.map_err(|e| { - logger::error(&format!("Failed to read directory {:?}: {}", source_dir, e)); - DomainError::InternalError(format!("Failed to read directory: {}", e)) - })?; - - // Process each entry - while let Some(entry) = entries.next_entry().await.map_err(|e| { - logger::error(&format!("Failed to read directory entry: {}", e)); - DomainError::InternalError(format!("Failed to read directory entry: {}", e)) - })? { - let source_path = entry.path(); - let dest_path = dest_dir.join(source_path.file_name().unwrap()); - - if source_path.is_dir() { - // Recursively copy subdirectory - Box::pin(self.copy_directory(&source_path, &dest_path)).await?; - } else { - // Copy file - self.copy_file(&source_path, &dest_path).await?; - } + fn expand_resource_entries(&self, filename: &str) -> Result, DomainError> { + if !Self::is_directory_entry(filename) { + return Ok(vec![filename.to_string()]); } - Ok(()) + let entries = list_default_content_files_under(filename); + if entries.is_empty() { + return Err(DomainError::NotFound(format!( + "Resource directory is empty or missing: {}", + filename + ))); + } + + Ok(entries) + } + + fn build_destination_path( + &self, + item: &ContentItem, + resource_entry: &str, + target_dir: &Path, + ) -> Result { + if Self::is_directory_entry(&item.filename) { + let dir_name = Path::new(&item.filename).file_name().ok_or_else(|| { + DomainError::InvalidData(format!("Invalid directory entry: {}", item.filename)) + })?; + + let prefix = format!("{}/", item.filename.trim_matches('/').replace('\\', "/")); + let relative_entry = resource_entry + .strip_prefix(&prefix) + .unwrap_or(resource_entry); + + return Ok(target_dir.join(dir_name).join(relative_entry)); + } + + let base_filename = Path::new(&item.filename).file_name().ok_or_else(|| { + DomainError::InvalidData(format!("Invalid filename: {}", item.filename)) + })?; + + Ok(target_dir.join(base_filename)) } } @@ -208,22 +186,6 @@ impl ContentRepository for FileContentRepository { continue; } - // Resolve the source path using Tauri's resource system - let source_path = self - .app_handle - .path() - .resolve( - &format!("default/content/{}", item.filename), - BaseDirectory::Resource, - ) - .map_err(|e| { - logger::error(&format!( - "Failed to resolve source path for {}: {}", - item.filename, e - )); - DomainError::InternalError(format!("Failed to resolve source path: {}", e)) - })?; - // Get the target directory based on content type let target_dir = self.get_target_directory(&item.content_type, &user_dir); @@ -236,21 +198,27 @@ impl ContentRepository for FileContentRepository { DomainError::InternalError(format!("Failed to create target directory: {}", e)) })?; - // Get the base filename - let base_filename = Path::new(&item.filename).file_name().ok_or_else(|| { - logger::error(&format!("Invalid filename: {}", item.filename)); - DomainError::InvalidData(format!("Invalid filename: {}", item.filename)) - })?; + let resource_entries = self.expand_resource_entries(&item.filename)?; - // Create destination path - let dest_path = target_dir.join(base_filename); + for resource_entry in resource_entries { + let resource_path = format!("default/content/{}", resource_entry); + let dest_path = + self.build_destination_path(&item, &resource_entry, &target_dir)?; - logger::debug(&format!("Copying {} to {:?}", item.filename, dest_path)); + if dest_path.exists() { + logger::debug(&format!( + "Skipping copy, file already exists: {:?}", + dest_path + )); + continue; + } - if source_path.is_dir() { - Box::pin(self.copy_directory(&source_path, &dest_path)).await?; - } else { - self.copy_file(&source_path, &dest_path).await?; + logger::debug(&format!( + "Copying {} to {:?}", + resource_path, dest_path + )); + + copy_resource_to_file(&self.app_handle, &resource_path, &dest_path).await?; } // Add to content log @@ -270,23 +238,8 @@ impl ContentRepository for FileContentRepository { } async fn get_content_index(&self) -> Result, DomainError> { - // Resolve the path to the content index file using Tauri's resource system - let content_index_path = self - .app_handle - .path() - .resolve("default/content/index.json", BaseDirectory::Resource) - .map_err(|e| { - logger::error(&format!("Failed to resolve content index path: {}", e)); - DomainError::InternalError(format!("Failed to resolve content index path: {}", e)) - })?; - - logger::debug(&format!( - "Reading content index from {:?}", - content_index_path - )); - - // Read content index - let index_items: Vec = read_json_file(&content_index_path).await?; + let index_items: Vec = + read_resource_json(&self.app_handle, "default/content/index.json")?; // Convert to domain model let content_items = index_items diff --git a/src-tauri/src/infrastructure/repositories/file_extension_repository.rs b/src-tauri/src/infrastructure/repositories/file_extension_repository.rs index f0764d64..d111d976 100644 --- a/src-tauri/src/infrastructure/repositories/file_extension_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_extension_repository.rs @@ -14,6 +14,7 @@ use crate::domain::models::extension::{ }; use crate::domain::repositories::extension_repository::ExtensionRepository; use crate::infrastructure::logging::logger; +use crate::infrastructure::paths::resolve_app_data_dir; use crate::infrastructure::persistence::file_system::read_json_file; pub struct FileExtensionRepository { @@ -48,10 +49,8 @@ struct GithubBranchCommit { impl FileExtensionRepository { pub fn new(app_handle: AppHandle) -> Self { // Get app data directory - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); + let app_data_dir = + resolve_app_data_dir(&app_handle).expect("Failed to get app data directory"); // Construct extension directories let data_root = app_data_dir.join("data"); diff --git a/src-tauri/src/infrastructure/repositories/file_preset_repository.rs b/src-tauri/src/infrastructure/repositories/file_preset_repository.rs index d19bf597..20cf2551 100644 --- a/src-tauri/src/infrastructure/repositories/file_preset_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_preset_repository.rs @@ -2,14 +2,13 @@ use async_trait::async_trait; use serde_json::Value; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::path::BaseDirectory; use tauri::AppHandle; -use tauri::Manager; use crate::domain::errors::DomainError; use crate::domain::models::preset::{sanitize_filename, DefaultPreset, Preset, PresetType}; use crate::domain::repositories::content_repository::ContentRepository; use crate::domain::repositories::preset_repository::PresetRepository; +use crate::infrastructure::assets::read_resource_json; use crate::infrastructure::logging::logger; use crate::infrastructure::persistence::file_system::{ delete_file, list_files_with_extension, read_json_file, write_json_file, @@ -129,25 +128,10 @@ impl FilePresetRepository { if item_name == name { // Found matching preset, load it - let content_path = self - .app_handle - .path() - .resolve( - &format!("default/content/{}", item.filename), - BaseDirectory::Resource, - ) - .map_err(|e| { - logger::error(&format!( - "Failed to resolve content path for {}: {}", - item.filename, e - )); - DomainError::InternalError(format!( - "Failed to resolve content path: {}", - e - )) - })?; - - let data: Value = read_json_file(&content_path).await?; + let data: Value = read_resource_json( + &self.app_handle, + &format!("default/content/{}", item.filename), + )?; return Ok(Some(DefaultPreset { filename: item.filename, diff --git a/src-tauri/src/infrastructure/repositories/file_secret_repository.rs b/src-tauri/src/infrastructure/repositories/file_secret_repository.rs index e97f6ba3..38354ff0 100644 --- a/src-tauri/src/infrastructure/repositories/file_secret_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_secret_repository.rs @@ -3,7 +3,6 @@ use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; use tauri::AppHandle; -use tauri::Manager; use tokio::fs; use tokio::sync::Mutex; @@ -11,6 +10,7 @@ use crate::domain::errors::DomainError; use crate::domain::models::secret::{SecretEntry, SecretKeys, Secrets}; use crate::domain::repositories::secret_repository::SecretRepository; use crate::infrastructure::logging::logger; +use crate::infrastructure::paths::resolve_app_data_dir; use crate::infrastructure::persistence::file_system::{read_json_file, write_json_file}; pub struct FileSecretRepository { @@ -21,10 +21,8 @@ pub struct FileSecretRepository { impl FileSecretRepository { pub fn new(app_handle: AppHandle) -> Self { // 使用 Tauri 的 app_data_dir 获取应用数据目录 - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); + let app_data_dir = + resolve_app_data_dir(&app_handle).expect("Failed to get app data directory"); // 构建 secrets.json 文件路径 let secrets_file = app_data_dir diff --git a/src-tauri/src/infrastructure/repositories/file_theme_repository.rs b/src-tauri/src/infrastructure/repositories/file_theme_repository.rs index 56c8fdbe..909f2993 100644 --- a/src-tauri/src/infrastructure/repositories/file_theme_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_theme_repository.rs @@ -1,12 +1,12 @@ use async_trait::async_trait; use std::path::PathBuf; use tauri::AppHandle; -use tauri::Manager; use crate::domain::errors::DomainError; use crate::domain::models::theme::{sanitize_filename, Theme}; use crate::domain::repositories::theme_repository::ThemeRepository; use crate::infrastructure::logging::logger; +use crate::infrastructure::paths::resolve_app_data_dir; use crate::infrastructure::persistence::file_system::{ delete_file, write_json_file, }; @@ -21,10 +21,8 @@ impl FileThemeRepository { /// Create a new FileThemeRepository pub fn new(app_handle: AppHandle) -> Self { // Get the themes directory from the app data directory - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); + let app_data_dir = + resolve_app_data_dir(&app_handle).expect("Failed to get app data directory"); let themes_dir = app_data_dir .join("data") .join("default-user") diff --git a/src-tauri/src/infrastructure/repositories/file_user_directory_repository.rs b/src-tauri/src/infrastructure/repositories/file_user_directory_repository.rs index 8e1d5647..b9d08bcc 100644 --- a/src-tauri/src/infrastructure/repositories/file_user_directory_repository.rs +++ b/src-tauri/src/infrastructure/repositories/file_user_directory_repository.rs @@ -1,13 +1,13 @@ use async_trait::async_trait; use std::path::PathBuf; use tauri::AppHandle; -use tauri::Manager; use tokio::fs; use crate::domain::errors::DomainError; use crate::domain::models::user_directory::UserDirectory; use crate::domain::repositories::user_directory_repository::UserDirectoryRepository; use crate::infrastructure::logging::logger; +use crate::infrastructure::paths::resolve_app_data_dir; pub struct FileUserDirectoryRepository { data_root: PathBuf, @@ -16,10 +16,8 @@ pub struct FileUserDirectoryRepository { impl FileUserDirectoryRepository { pub fn new(app_handle: AppHandle) -> Self { // 使用 Tauri 的 app_data_dir 获取应用数据目录 - let app_data_dir = app_handle - .path() - .app_data_dir() - .expect("Failed to get app data directory"); + let app_data_dir = + resolve_app_data_dir(&app_handle).expect("Failed to get app data directory"); // 构建数据根目录 let data_root = app_data_dir.join("data"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd685c53..dc02844c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod presentation; use app::{resolve_data_root, resolve_log_root, spawn_initialization}; use infrastructure::logging::logger; +use tauri::Manager; use presentation::commands::registry::invoke_handler; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -33,6 +34,16 @@ pub async fn run() { tracing::info!("Starting TauriTavern application"); let data_root = resolve_data_root(&app_handle)?; + if let Err(error) = app_handle + .asset_protocol_scope() + .allow_directory(&data_root, true) + { + tracing::warn!( + "Failed to extend asset protocol scope for {:?}: {}", + data_root, + error + ); + } spawn_initialization(app_handle, data_root); Ok(()) }) diff --git a/src-tauri/src/presentation/commands/bridge.rs b/src-tauri/src/presentation/commands/bridge.rs index 17d3033d..a3ea17aa 100644 --- a/src-tauri/src/presentation/commands/bridge.rs +++ b/src-tauri/src/presentation/commands/bridge.rs @@ -1,9 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -use tauri::path::BaseDirectory; -use tauri::{Emitter, Manager, Window}; -use tauri_plugin_fs::FsExt; +use tauri::{Emitter, Window}; +use crate::infrastructure::assets::read_resource_text; use crate::presentation::commands::helpers::{log_command, map_command_error}; use crate::presentation::errors::CommandError; @@ -88,18 +87,14 @@ pub fn read_frontend_template(app: tauri::AppHandle, name: String) -> Result CommandError::NotFound(message), + other => CommandError::InternalServerError(format!( + "Failed to read template '{}': {}", + name, other + )), + } })?; Ok(content) diff --git a/src/css/mobile-styles.css b/src/css/mobile-styles.css index 01abb6ab..ae643e4b 100644 --- a/src/css/mobile-styles.css +++ b/src/css/mobile-styles.css @@ -217,8 +217,11 @@ #top-settings-holder, #top-bar { position: fixed; + top: max(var(--tt-safe-area-top), 0px); width: 100vw; width: 100dvw; + padding-right: max(var(--tt-safe-area-right), 0px); + padding-left: max(var(--tt-safe-area-left), 0px); } #bg1, @@ -240,7 +243,7 @@ max-width: 100dvw; left: 0 !important; resize: none !important; - top: var(--topBarBlockSize); + top: calc(var(--topBarBlockSize) + max(var(--tt-safe-area-top), 0px)); } .wi-settings { @@ -273,6 +276,11 @@ max-width: 100dvw; } + #form_sheld { + padding-right: max(var(--tt-safe-area-right), 0px); + padding-left: max(var(--tt-safe-area-left), 0px); + } + #chat { border-left: 1px solid var(--SmartThemeBorderColor); border-right: 1px solid var(--SmartThemeBorderColor); @@ -315,7 +323,7 @@ border-right: 1px solid var(--SmartThemeBorderColor); border-bottom: 1px solid var(--SmartThemeBorderColor); border-radius: 0 0 20px 20px; - top: var(--topBarBlockSize) !important; + top: calc(var(--topBarBlockSize) + max(var(--tt-safe-area-top), 0px)) !important; left: 0 !important; backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2)); @@ -358,7 +366,7 @@ width: 100%; border-radius: 0 0 20px 20px; margin-top: 0px; - height: calc(100% - var(--topBarBlockSize)); + height: calc(100% - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px)); } .drawer25pWidth { diff --git a/src/style.css b/src/style.css index ef96305c..7dcf59a7 100644 --- a/src/style.css +++ b/src/style.css @@ -112,6 +112,11 @@ --topBarBlockSize: calc(var(--topBarIconSize) + var(--topBarBlockPadding)); --topBarBlockPadding: calc(var(--mainFontSize) / 3); + /* Android WebView can report zero env(safe-area-*) values. Native layer overrides these on Android. */ + --tt-safe-area-top: env(safe-area-inset-top, 0px); + --tt-safe-area-right: env(safe-area-inset-right, 0px); + --tt-safe-area-left: env(safe-area-inset-left, 0px); + /*styles for the color picker*/ --tool-cool-color-picker-btn-bg: transparent; --tool-cool-color-picker-btn-border-color: transparent; diff --git a/src/tauri/main/context.js b/src/tauri/main/context.js index b14bceba..e582f458 100644 --- a/src/tauri/main/context.js +++ b/src/tauri/main/context.js @@ -121,7 +121,7 @@ export function createTauriMainContext({ invoke, convertFileSrc }) { try { return await invoke(command, args); } catch (error) { - const message = error?.message || error?.toString?.() || `Command failed: ${command}`; + const message = normalizeInvokeErrorMessage(error, `Command failed: ${command}`); if (attempt < 19 && shouldRetryInvoke(message)) { await sleep(200); continue; @@ -139,6 +139,72 @@ export function createTauriMainContext({ invoke, convertFileSrc }) { ); } + function normalizeInvokeErrorMessage(error, fallback) { + const extracted = extractErrorText(error); + if (extracted && extracted !== '[object Object]') { + return extracted; + } + + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== '{}') { + return serialized; + } + } catch { + // Ignore serialization failure and continue fallback chain. + } + + const stringified = String(error || '').trim(); + if (stringified && stringified !== '[object Object]') { + return stringified; + } + + return fallback; + } + + function extractErrorText(value, depth = 0) { + if (depth > 4 || value === null || value === undefined) { + return ''; + } + + if (typeof value === 'string') { + return value.trim(); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (value instanceof Error) { + const nested = extractErrorText(value.message, depth + 1); + return nested || String(value).trim(); + } + + if (Array.isArray(value)) { + for (const item of value) { + const nested = extractErrorText(item, depth + 1); + if (nested) { + return nested; + } + } + return ''; + } + + if (typeof value === 'object') { + const keys = ['message', 'error', 'details', 'reason', 'cause', 'data']; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const nested = extractErrorText(value[key], depth + 1); + if (nested) { + return nested; + } + } + } + } + + return ''; + } + function normalizeCharacter(character) { if (!character || typeof character !== 'object') { return character; diff --git a/src/tauri/main/routes/chat-routes.js b/src/tauri/main/routes/chat-routes.js index 4f8ba469..b45dc52d 100644 --- a/src/tauri/main/routes/chat-routes.js +++ b/src/tauri/main/routes/chat-routes.js @@ -1,6 +1,17 @@ export function registerChatRoutes(router, context, { jsonResponse }) { const isChatNotFoundError = (error) => { - const message = String(error?.message || error || '').toLowerCase(); + const serialized = (() => { + try { + return JSON.stringify(error); + } catch { + return ''; + } + })(); + + const message = [error?.message, error, serialized] + .map((value) => String(value || '')) + .join(' ') + .toLowerCase(); return ( message.includes('not found') || message.includes('no such file') ||