fix: Android resource path access and edge-to-edge design

This commit is contained in:
Darkatse 2026-02-14 06:38:31 +08:00
parent f6414b754e
commit b87ebe2e06
24 changed files with 866 additions and 183 deletions

164
docs/MobileDevelopment.md Normal file
View file

@ -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`),避免再次把平台差异扩散到业务代码。

View file

@ -9,6 +9,7 @@
3. [前端指南](./FrontendGuide.md) - 前端代码结构、Tauri注入启动链与模块化路由开发指南
4. [后端结构](./BackendStructure.md) - 后端架构和模块说明
5. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
6. [移动端开发说明](./MobileDevelopment.md) - Android/iOS 关键坑位、资源访问与路径解析方案
## 项目概述

View file

@ -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"] }

View file

@ -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<dyn Error>> {
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::<Vec<_>>(),
);
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::<Vec<_>>(),
);
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<Vec<String>, Box<dyn Error>> {
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<String, Box<dyn Error>> {
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)
}

View file

@ -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()
}
}

View file

@ -1,6 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tauritavern" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:enforceNavigationBarContrast">false</item>
</style>
</resources>

View file

@ -1,6 +1,11 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.tauritavern" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:enforceNavigationBarContrast">false</item>
</style>
</resources>

View file

@ -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<PathBuf, Box<dyn std::
Ok(log_root)
}
fn resolve_app_data_dir(app_handle: &AppHandle) -> Result<PathBuf, Box<dyn std::error::Error>> {
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 {

View file

@ -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<Vec<String>> = 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<String, DomainError> {
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<Vec<u8>, 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<T: DeserializeOwned>(
app_handle: &AppHandle,
relative_path: &str,
) -> Result<T, DomainError> {
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<String> {
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<String> {
let raw = include_str!(concat!(env!("OUT_DIR"), "/default_content_manifest.json"));
match serde_json::from_str::<Vec<String>>(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<std::path::PathBuf, DomainError> {
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<String, DomainError> {
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))
}
}

View file

@ -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;

View file

@ -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<PathBuf, Box<dyn Error>> {
#[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<PathBuf> {
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/")
}

View file

@ -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

View file

@ -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<Vec<String>, 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<PathBuf, DomainError> {
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<Vec<ContentItem>, 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<ContentIndexItem> = read_json_file(&content_index_path).await?;
let index_items: Vec<ContentIndexItem> =
read_resource_json(&self.app_handle, "default/content/index.json")?;
// Convert to domain model
let content_items = index_items

View file

@ -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");

View file

@ -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,

View file

@ -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

View file

@ -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")

View file

@ -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");

View file

@ -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(())
})

View file

@ -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<Str
)));
}
let resource_path = app
.path()
.resolve(
format!("frontend-templates/{}", name),
BaseDirectory::Resource,
)
.map_err(|e| {
CommandError::InternalServerError(format!("Failed to resolve template path: {}", e))
})?;
let content = app.fs().read_to_string(&resource_path).map_err(|e| {
CommandError::NotFound(format!("Failed to read template '{}': {}", name, e))
let content = read_resource_text(&app, &format!("frontend-templates/{}", name)).map_err(|e| {
match e {
crate::domain::errors::DomainError::NotFound(message) => CommandError::NotFound(message),
other => CommandError::InternalServerError(format!(
"Failed to read template '{}': {}",
name, other
)),
}
})?;
Ok(content)

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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') ||