refactor(data-archive): close platform boundaries for archive delivery

This commit is contained in:
Darkatse 2026-06-28 16:20:47 -04:00
parent 86ce49b595
commit e6111d6363
20 changed files with 192 additions and 64 deletions

View file

@ -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 # 命令日志/错误映射公共工具

View file

@ -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 元数据导致的“布局歧义”问题

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {}",

View file

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

View file

@ -2,6 +2,7 @@ mod app;
mod application;
mod domain;
mod infrastructure;
mod platform;
mod presentation;
// This module is the Tauri host composition root.

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
});
});