mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 07:35:14 +00:00
refactor(data-archive): close platform boundaries for archive delivery
This commit is contained in:
parent
86ce49b595
commit
e6111d6363
20 changed files with 192 additions and 64 deletions
|
|
@ -42,6 +42,7 @@ TauriTavern的后端采用Clean Architecture架构,将代码组织为多个层
|
|||
- 外层可以依赖内层,但内层不能依赖外层
|
||||
- 内层定义接口,外层实现接口
|
||||
- 所有层次都可以使用领域模型和错误类型
|
||||
- `platform` 是宿主平台适配层,承载 iOS/UIKit、macOS/WebView 等 OS 级能力;`domain` 与 `application` 不得依赖它,`platform` 也不得依赖 `app`、`application`、`infrastructure` 或 `presentation`
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ src-tauri/
|
|||
│ │ ├── repositories/ # 仓库实现
|
||||
│ │ ├── apis/ # 外部API集成
|
||||
│ │ └── logging/ # 日志系统
|
||||
│ ├── platform/ # 宿主平台适配(UIKit/WebView/native UI)
|
||||
│ └── presentation/ # 表示层
|
||||
│ ├── commands/ # Tauri命令
|
||||
│ │ ├── helpers.rs # 命令日志/错误映射公共工具
|
||||
|
|
|
|||
|
|
@ -91,10 +91,11 @@ iOS 上“文件选择 / 文件导出”必须交给系统级能力完成:
|
|||
- 前端扩展入口:`src/scripts/extensions/data-migration/index.js`
|
||||
- Host Kernel 路由:`src/tauri/main/routes/extensions-routes.js`
|
||||
- iOS-only Tauri commands:`src-tauri/src/presentation/commands/ios_file_bridge_commands.rs`
|
||||
- iOS UIKit 封装:
|
||||
- `src-tauri/src/infrastructure/ios_ui.rs`
|
||||
- `src-tauri/src/infrastructure/ios_document_picker.rs`
|
||||
- `src-tauri/src/infrastructure/ios_share_sheet.rs`
|
||||
- iOS UIKit / picker / share host adapter:
|
||||
- `src-tauri/src/platform/ios_ui.rs`
|
||||
- `src-tauri/src/platform/ios_document_picker.rs`
|
||||
- `src-tauri/src/platform/ios_share_sheet.rs`
|
||||
- Data Archive import staging 只暴露 `prepare_data_archive_import_target_path`;不要恢复旧的 `get_data_archive_imports_root`,避免把 staging root 交给前端自行拼路径。
|
||||
|
||||
### 2.5 macOS 元数据导致的“布局歧义”问题
|
||||
|
||||
|
|
|
|||
|
|
@ -205,11 +205,6 @@ impl DataArchiveService {
|
|||
Ok(job_id)
|
||||
}
|
||||
|
||||
pub fn imports_root(&self) -> PathBuf {
|
||||
self.files.imports_root()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError> {
|
||||
self.files.prepare_incoming_import_archive_path()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ pub(crate) trait DataArchiveExecutor: Send + Sync {
|
|||
}
|
||||
|
||||
pub(crate) trait DataArchiveFileGateway: Send + Sync {
|
||||
fn imports_root(&self) -> PathBuf;
|
||||
#[cfg(target_os = "ios")]
|
||||
fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError>;
|
||||
fn prepare_import_archive(
|
||||
&self,
|
||||
|
|
|
|||
|
|
@ -51,11 +51,6 @@ impl DataArchiveExecutor for UnusedExecutor {
|
|||
struct UnusedFiles;
|
||||
|
||||
impl DataArchiveFileGateway for UnusedFiles {
|
||||
fn imports_root(&self) -> PathBuf {
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
@ -282,11 +277,6 @@ impl RecordingFiles {
|
|||
}
|
||||
|
||||
impl DataArchiveFileGateway for RecordingFiles {
|
||||
fn imports_root(&self) -> PathBuf {
|
||||
PathBuf::from("/tmp/imports")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ mod platform {
|
|||
UIAlertAction, UIAlertActionStyle, UIAlertController, UIAlertControllerStyle, UITextField,
|
||||
};
|
||||
|
||||
use crate::infrastructure::ios_ui::resolve_presenting_view_controller;
|
||||
use crate::platform::ios_ui::resolve_presenting_view_controller;
|
||||
|
||||
pub(super) fn present_alert(message: &NSString, completion_handler: RcBlock<dyn Fn()>) {
|
||||
let presenting = match resolve_presenting_view_controller() {
|
||||
|
|
|
|||
|
|
@ -9,14 +9,8 @@ pub mod github;
|
|||
pub mod http_client;
|
||||
pub mod http_client_pool;
|
||||
pub mod http_error;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_document_picker;
|
||||
pub mod ios_policy_cache;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_share_sheet;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_ui;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_webview;
|
||||
pub mod lan_sync;
|
||||
pub mod logging;
|
||||
|
|
@ -40,5 +34,7 @@ pub mod user_data_dirs;
|
|||
pub mod user_data_paths;
|
||||
pub mod zipkit;
|
||||
|
||||
#[cfg(test)]
|
||||
mod platform_boundary_contract_tests;
|
||||
#[cfg(test)]
|
||||
mod webview_js_dialogs_contract_tests;
|
||||
|
|
|
|||
|
|
@ -98,16 +98,12 @@ impl TauriDataArchiveFileGateway {
|
|||
}
|
||||
|
||||
impl DataArchiveFileGateway for TauriDataArchiveFileGateway {
|
||||
fn imports_root(&self) -> PathBuf {
|
||||
self.app_handle
|
||||
fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError> {
|
||||
let incoming_dir = self
|
||||
.app_handle
|
||||
.state::<RuntimePaths>()
|
||||
.archive_imports_root
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "ios")]
|
||||
fn prepare_incoming_import_archive_path(&self) -> Result<PathBuf, DomainError> {
|
||||
let incoming_dir = self.imports_root().join("incoming");
|
||||
.join("incoming");
|
||||
fs::create_dir_all(&incoming_dir).map_err(|error| {
|
||||
DomainError::InternalError(format!(
|
||||
"Failed to create import staging directory {}: {}",
|
||||
|
|
@ -247,8 +243,7 @@ impl DataArchiveFileGateway for TauriDataArchiveFileGateway {
|
|||
file_name: &str,
|
||||
) -> Result<PathBuf, DomainError> {
|
||||
let source_path = resolve_staged_user_backup_archive_path(&self.app_handle, archive_path)?;
|
||||
let file_name = validate_archive_file_name(file_name)?;
|
||||
save_staged_archive_to_downloads(&self.app_handle, &source_path, &file_name)
|
||||
save_staged_archive_to_downloads(&self.app_handle, &source_path, file_name)
|
||||
}
|
||||
|
||||
fn cleanup_user_backup(&self, archive_path: &str) -> Result<(), DomainError> {
|
||||
|
|
@ -287,6 +282,7 @@ fn save_staged_archive_to_downloads(
|
|||
)));
|
||||
}
|
||||
|
||||
let file_name = validate_archive_file_name(file_name)?;
|
||||
let download_dir = app_handle.path().download_dir().map_err(|error| {
|
||||
DomainError::InternalError(format!("Failed to resolve downloads directory: {}", error))
|
||||
})?;
|
||||
|
|
@ -298,7 +294,7 @@ fn save_staged_archive_to_downloads(
|
|||
))
|
||||
})?;
|
||||
|
||||
let target_path = download_dir.join(file_name);
|
||||
let target_path = download_dir.join(&file_name);
|
||||
if target_path.exists() {
|
||||
return Err(DomainError::InvalidData(format!(
|
||||
"Export target already exists: {}",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn platform_boundary_stays_out_of_inner_layers() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
|
||||
assert_no_layer_refs(&root.join("domain"), "platform");
|
||||
assert_no_layer_refs(&root.join("application"), "platform");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_adapter_does_not_call_back_into_app_layers() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/platform");
|
||||
|
||||
for forbidden in ["app", "application", "infrastructure", "presentation"] {
|
||||
assert_no_layer_refs(&root, forbidden);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_layer_refs(root: &Path, forbidden: &str) {
|
||||
for path in rust_files(root) {
|
||||
let source = fs::read_to_string(&path).unwrap_or_else(|error| {
|
||||
panic!("read {}: {}", path.display(), error);
|
||||
});
|
||||
let code = source
|
||||
.lines()
|
||||
.map(|line| line.split("//").next().unwrap_or(""))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
!code.contains(&format!("crate::{forbidden}"))
|
||||
&& !code.contains(&format!("{forbidden}::")),
|
||||
"{} must not reference `{forbidden}`",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rust_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
collect_rust_files(root, &mut files);
|
||||
files
|
||||
}
|
||||
|
||||
fn collect_rust_files(path: &Path, files: &mut Vec<PathBuf>) {
|
||||
if path.is_file() {
|
||||
if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(path).unwrap_or_else(|error| {
|
||||
panic!("read directory {}: {}", path.display(), error);
|
||||
}) {
|
||||
let entry = entry.expect("read directory entry");
|
||||
collect_rust_files(&entry.path(), files);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ mod app;
|
|||
mod application;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
mod platform;
|
||||
mod presentation;
|
||||
|
||||
// This module is the Tauri host composition root.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use tauri::WebviewWindow;
|
|||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::ios_ui::resolve_presenting_view_controller;
|
||||
use crate::platform::ios_ui::resolve_presenting_view_controller;
|
||||
|
||||
const DATA_ARCHIVE_CONTENT_TYPES: &[&str] = &[
|
||||
"public.zip-archive",
|
||||
|
|
@ -10,7 +10,7 @@ use tauri::WebviewWindow;
|
|||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::ios_ui::resolve_presenting_view_controller;
|
||||
use crate::platform::ios_ui::resolve_presenting_view_controller;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareResult {
|
||||
6
src-tauri/src/platform/mod.rs
Normal file
6
src-tauri/src/platform/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_document_picker;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_share_sheet;
|
||||
#[cfg(target_os = "ios")]
|
||||
pub mod ios_ui;
|
||||
|
|
@ -37,16 +37,19 @@ pub fn start_export_data_archive(
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_data_archive_imports_root(
|
||||
pub fn prepare_data_archive_import_target_path(
|
||||
app_state: State<'_, Arc<AppState>>,
|
||||
) -> Result<String, CommandError> {
|
||||
log_command("get_data_archive_imports_root");
|
||||
log_command("prepare_data_archive_import_target_path");
|
||||
|
||||
Ok(app_state
|
||||
let path = app_state
|
||||
.data_archive_service
|
||||
.imports_root()
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
.prepare_incoming_import_archive_path()
|
||||
.map_err(map_command_error(
|
||||
"Failed to prepare data archive import target path",
|
||||
))?;
|
||||
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ use tauri::{AppHandle, Manager, State, WebviewWindow};
|
|||
use crate::app::AppState;
|
||||
use crate::application::host_contract::IOS_EXPORT_STAGING_ROOT_NAME;
|
||||
use crate::domain::errors::DomainError;
|
||||
use crate::infrastructure::ios_document_picker::{
|
||||
use crate::platform::ios_document_picker::{
|
||||
PickDocumentResult, copy_picked_url_to_path, pick_data_archive, pick_skill_import_archive,
|
||||
};
|
||||
use crate::infrastructure::ios_share_sheet::share_file;
|
||||
use crate::platform::ios_share_sheet::share_file;
|
||||
use crate::presentation::commands::helpers::{log_command, map_command_error};
|
||||
use crate::presentation::errors::CommandError;
|
||||
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
|
|||
// Data archive commands
|
||||
super::data_archive_commands::start_import_data_archive,
|
||||
super::data_archive_commands::start_export_data_archive,
|
||||
super::data_archive_commands::get_data_archive_imports_root,
|
||||
super::data_archive_commands::prepare_data_archive_import_target_path,
|
||||
super::data_archive_commands::get_data_archive_job_status,
|
||||
super::data_archive_commands::cancel_data_archive_job,
|
||||
super::data_archive_commands::save_export_data_archive,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,6 @@ export {};
|
|||
* | 'get_chat_payload_before_pages'
|
||||
* | 'get_bootstrap_snapshot'
|
||||
* | 'get_client_version'
|
||||
* | 'get_data_archive_imports_root'
|
||||
* | 'get_data_archive_job_status'
|
||||
* | 'get_default_user_directory'
|
||||
* | 'get_extension_version'
|
||||
|
|
@ -156,6 +155,7 @@ export {};
|
|||
* | 'cancel_agent_run'
|
||||
* | 'apply_agent_current_model_connection_snapshot'
|
||||
* | 'preview_skill_import'
|
||||
* | 'prepare_data_archive_import_target_path'
|
||||
* | 'prepare_agent_prompt_assembly'
|
||||
* | 'build_agent_current_model_connection_snapshot'
|
||||
* | 'sanitize_filename'
|
||||
|
|
|
|||
|
|
@ -110,14 +110,6 @@ export function createAndroidArchiveService({ safeInvoke, removeTempUploadFile,
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ join: (...paths: string[]) => Promise<string> }} pathApi
|
||||
*/
|
||||
async function resolveAndroidImportStagingDirectory(pathApi) {
|
||||
const importsRoot = String(await safeInvoke('get_data_archive_imports_root')).trim();
|
||||
return pathApi.join(importsRoot, 'incoming');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ appCacheDir?: () => Promise<string>; tempDir?: () => Promise<string>; join: (...paths: string[]) => Promise<string> }} pathApi
|
||||
*/
|
||||
|
|
@ -161,11 +153,10 @@ export function createAndroidArchiveService({ safeInvoke, removeTempUploadFile,
|
|||
if (!bridge) {
|
||||
throw new Error('Android archive bridge is unavailable');
|
||||
}
|
||||
const pathApi = window.__TAURI__.path;
|
||||
|
||||
const stagingDirectory = await resolveAndroidImportStagingDirectory(pathApi);
|
||||
const targetFileName = `tauritavern-import-${Date.now()}-${Math.random().toString(16).slice(2)}.archive`;
|
||||
const targetFilePath = await pathApi.join(stagingDirectory, targetFileName);
|
||||
const targetFilePath = String(await safeInvoke('prepare_data_archive_import_target_path')).trim();
|
||||
if (!targetFilePath) {
|
||||
throw new Error('Android import staging target is missing');
|
||||
}
|
||||
const filePath = String(
|
||||
bridge.stageContentUriToFile(String(contentUri).trim(), targetFilePath),
|
||||
).trim();
|
||||
|
|
|
|||
89
tests/android-archive-service-contract.test.mjs
Normal file
89
tests/android-archive-service-contract.test.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createAndroidArchiveService } from '../src/tauri/main/services/android/android-archive-service.js';
|
||||
|
||||
async function withWindow(value, callback) {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis, 'window', original);
|
||||
} else {
|
||||
delete globalThis.window;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('Android data archive import asks Rust for fresh staging target paths', async () => {
|
||||
const bridgeName = '__TAURITAVERN_TEST_ANDROID_ARCHIVE__';
|
||||
const targetPaths = [
|
||||
'/cache/.data-archive/imports/incoming/import-one.archive',
|
||||
'/cache/.data-archive/imports/incoming/import-two.archive',
|
||||
];
|
||||
const safeCalls = [];
|
||||
const staged = [];
|
||||
|
||||
await withWindow({
|
||||
__TAURI__: { core: { invoke: async () => null } },
|
||||
[bridgeName]: {
|
||||
stageContentUriToFile(contentUri, targetFilePath) {
|
||||
staged.push({ contentUri, targetFilePath });
|
||||
return `${targetFilePath}.staged`;
|
||||
},
|
||||
requestImportArchivePicker() {},
|
||||
requestExportArchivePicker() {},
|
||||
copyFileToContentUri() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
}, async () => {
|
||||
const service = createAndroidArchiveService({
|
||||
bridgeName,
|
||||
safeInvoke: async (command, args) => {
|
||||
safeCalls.push({ command, args });
|
||||
assert.equal(command, 'prepare_data_archive_import_target_path');
|
||||
return targetPaths.shift();
|
||||
},
|
||||
removeTempUploadFile: async () => {},
|
||||
});
|
||||
|
||||
const first = await service.materializeAndroidContentUriUpload(' content://import-one ');
|
||||
const second = await service.materializeAndroidContentUriUpload('content://import-two');
|
||||
|
||||
assert.deepEqual([first, second].map((file) => ({
|
||||
filePath: file.filePath,
|
||||
isTemporary: file.isTemporary,
|
||||
})), [
|
||||
{
|
||||
filePath: '/cache/.data-archive/imports/incoming/import-one.archive.staged',
|
||||
isTemporary: true,
|
||||
},
|
||||
{
|
||||
filePath: '/cache/.data-archive/imports/incoming/import-two.archive.staged',
|
||||
isTemporary: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(safeCalls, [
|
||||
{ command: 'prepare_data_archive_import_target_path', args: undefined },
|
||||
{ command: 'prepare_data_archive_import_target_path', args: undefined },
|
||||
]);
|
||||
assert.deepEqual(staged, [
|
||||
{
|
||||
contentUri: 'content://import-one',
|
||||
targetFilePath: '/cache/.data-archive/imports/incoming/import-one.archive',
|
||||
},
|
||||
{
|
||||
contentUri: 'content://import-two',
|
||||
targetFilePath: '/cache/.data-archive/imports/incoming/import-two.archive',
|
||||
},
|
||||
]);
|
||||
assert.equal(targetPaths.length, 0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue