mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
fix: Android resource path access and edge-to-edge design
This commit is contained in:
parent
f6414b754e
commit
b87ebe2e06
24 changed files with 866 additions and 183 deletions
164
docs/MobileDevelopment.md
Normal file
164
docs/MobileDevelopment.md
Normal 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`),避免再次把平台差异扩散到业务代码。
|
||||
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
3. [前端指南](./FrontendGuide.md) - 前端代码结构、Tauri注入启动链与模块化路由开发指南
|
||||
4. [后端结构](./BackendStructure.md) - 后端架构和模块说明
|
||||
5. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
|
||||
6. [移动端开发说明](./MobileDevelopment.md) - Android/iOS 关键坑位、资源访问与路径解析方案
|
||||
|
||||
## 项目概述
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
179
src-tauri/src/infrastructure/assets.rs
Normal file
179
src-tauri/src/infrastructure/assets.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
60
src-tauri/src/infrastructure/paths.rs
Normal file
60
src-tauri/src/infrastructure/paths.rs
Normal 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/")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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') ||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue