fix (iOS): Fixed import/export issues with the data-migration extension on iOS.

This commit is contained in:
Darkatse 2026-03-20 00:39:01 -04:00
parent 276de46878
commit 46ed58d51d
21 changed files with 994 additions and 55 deletions

View file

@ -12,9 +12,10 @@
6. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
7. [Android 端开发说明](./AndroidDevelopment.md) - Android WebView/Insets 注入、资源访问与路径解析方案
8. [iOS 端开发说明](./iOSDevelopment.md) - WKWebView 行为差异、safe-area/viewport-fit 与底部死区修复
9. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束
10. [性能重构计划:Prompt History Backfill](./PerformanceRefactoringPlan.md) - Windowed Payload 下补齐生成上下文的方案(方案 C + 页缓存)
11. [移动端 Insets 解耦重构计划](./MobileInsetsDecouplingPlan.md) - Android 原生注入与 iOS CSS safe-area 解耦,修复底部死区并收敛契约
9. [iOS Data Migration 原生桥接计划书](./iOSDataMigrationNativeBridge.md) - iOS Document Picker / Share Sheet 桥接,修复 data-migration 导入导出体验
10. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束
11. [性能重构计划:Prompt History Backfill](./PerformanceRefactoringPlan.md) - Windowed Payload 下补齐生成上下文的方案(方案 C + 页缓存)
12. [移动端 Insets 解耦重构计划](./MobileInsetsDecouplingPlan.md) - Android 原生注入与 iOS CSS safe-area 解耦,修复底部死区并收敛契约
## 项目概述

View file

@ -49,3 +49,58 @@ WKWebView 内部是 `UIScrollView` 承载 Web 内容;在默认行为下,iOS
- `screen.height - window.innerHeight` 接近 0(允许 1px 内的 rounding)
- `--tt-inset-bottom` 仍保持合理的 safe-area 值(如 `34px`),且输入框/按钮不被 home indicator 遮挡
## 2. data-migration:iOS 原生 Document Picker / Share Sheet 桥接
### 2.1 现象
- **导出**:UI 虽提示完成,但仅得到 iOS 沙盒内路径(对普通用户不可达),无法“拿到文件”。
- **导入**:能弹出文件选择器,但选择 zip 后无反馈/不启动导入。
### 2.2 根因(第一性原理)
iOS 上“文件选择 / 文件导出”必须交给系统级能力完成:
- WebView 无法向用户暴露可操作的沙盒路径(即使文件写入成功,用户也无法访问)。
- `<input type="file">` 在 WKWebView 上对 zip 的行为差异较大,不适合作为 data-migration 的唯一入口。
- `window.confirm()` 在 iOS WebView 上存在不可靠性(可能不弹出/阻塞),会导致“看起来无反应”。
### 2.3 已落地方案(当前状态:已稳定可用)
仅在 iOS 平台启用原生桥接:
1) **Import(Document Picker)**
- 使用 `UIDocumentPickerViewController` 选择 `.zip`。
- 将选中的 `file://` URL 复制到 app 内部 `archive_imports_root/incoming` staging,再启动现有 import job(job/轮询语义不变)。
2) **Export(Share Sheet)**
- export job 生成 zip 后,不再尝试“保存到 Downloads 并展示路径”。
- 直接使用 `UIActivityViewController` 打开 Share Sheet,让用户保存到 Files / AirDrop / 其它 App。
3) **UI 线程与呈现约束**
- 所有 UIKit present 均通过 `WebviewWindow::run_on_main_thread` 执行,并通过 `UIApplication.windows` 解析 top-most presenting VC。
- iPad 走 popoverPresentationController 绑定 sourceView/sourceRect,避免崩溃。
4) **确认弹窗**
- iOS 导入确认使用 `Popup.show.confirm`(避免 `window.confirm` 在 iOS 上不可靠)。
- 其他平台保持原语义不变。
### 2.4 重要实现位置(便于维护与回归)
- 前端扩展入口:`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`
### 2.5 macOS 元数据导致的“布局歧义”问题
部分 zip(尤其是从 macOS Finder 打包/转发)会携带 `__MACOSX/**` 资源分叉条目;它会在布局探测阶段制造“存在多个候选根”的假象,触发错误:
- `Invalid data: Archive layout is ambiguous`
当前实现会在 **布局扫描** 与 **解压归一化** 两阶段一致忽略 `__MACOSX` 条目,保证这类 zip 可正常导入:
- `src-tauri/src/infrastructure/persistence/data_archive/import/layout.rs`
- `src-tauri/src/infrastructure/persistence/data_archive/import/extract.rs`

15
src-tauri/Cargo.lock generated
View file

@ -2963,9 +2963,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.11.0",
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-uniform-type-identifiers",
]
[[package]]
name = "objc2-uniform-type-identifiers"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4"
dependencies = [
"objc2",
"objc2-foundation",
]
[[package]]
@ -5030,6 +5042,7 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"block2",
"bytes",
"chrono",
"filetime",
@ -5040,7 +5053,9 @@ dependencies = [
"miktik",
"mime_guess",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"objc2-uniform-type-identifiers",
"percent-encoding",
"png",
"qrcode",

View file

@ -61,7 +61,10 @@ webpki-roots = "1"
[target.'cfg(target_os = "ios")'.dependencies]
objc2 = "0.6.4"
objc2-ui-kit = { version = "0.3.2", default-features = false, features = ["std", "objc2-core-foundation", "UIGeometry", "UIScrollView"] }
block2 = "0.6.2"
objc2-uniform-type-identifiers = { version = "0.3.2", default-features = false, features = ["std", "UTType"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSError", "NSFileManager", "NSString", "NSURL"] }
objc2-ui-kit = { version = "0.3.2", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-uniform-type-identifiers", "UIActivity", "UIActivityViewController", "UIDocumentPickerViewController", "UIPopoverPresentationController", "UIPresentationController", "UIResponder", "UIScrollView", "UIView", "UIViewController", "UIWindow", "UIGeometry"] }
[features]
default = ["custom-protocol"]

View file

@ -548,10 +548,7 @@ impl CharacterService {
.trim();
if world_name.is_empty() {
if let Some(data_object) = export_value
.get_mut("data")
.and_then(Value::as_object_mut)
{
if let Some(data_object) = export_value.get_mut("data").and_then(Value::as_object_mut) {
data_object.remove("character_book");
}
return Ok(());
@ -631,11 +628,11 @@ mod tests {
use crate::domain::models::character::Character;
use crate::domain::repositories::character_repository::CharacterRepository;
use crate::domain::repositories::world_info_repository::WorldInfoRepository;
use crate::infrastructure::repositories::file_character_repository::FileCharacterRepository;
use crate::infrastructure::repositories::file_world_info_repository::FileWorldInfoRepository;
use crate::infrastructure::persistence::png_utils::{
read_character_data_from_png, write_character_data_to_png,
};
use crate::infrastructure::repositories::file_character_repository::FileCharacterRepository;
use crate::infrastructure::repositories::file_world_info_repository::FileWorldInfoRepository;
use image::{DynamicImage, ImageFormat, RgbaImage};
use rand::random;
use serde_json::json;
@ -644,19 +641,18 @@ mod tests {
use std::sync::Arc;
use tokio::fs;
async fn write_character_png(
root: &PathBuf,
file_stem: &str,
payload: &serde_json::Value,
) {
async fn write_character_png(root: &PathBuf, file_stem: &str, payload: &serde_json::Value) {
let png_bytes = write_character_data_to_png(
&build_minimal_png(),
&serde_json::to_string(payload).expect("serialize card payload"),
)
.expect("embed card in png");
fs::write(root.join("characters").join(format!("{}.png", file_stem)), png_bytes)
.await
.expect("write character png");
fs::write(
root.join("characters").join(format!("{}.png", file_stem)),
png_bytes,
)
.await
.expect("write character png");
}
fn unique_temp_root() -> PathBuf {

View file

@ -56,7 +56,7 @@ pub(super) fn parse_object(raw: &str) -> Result<Map<String, Value>, ApplicationE
return Err(ApplicationError::ValidationError(
"Custom parameter map must be an object or a list of objects."
.to_string(),
))
));
}
}
}
@ -94,12 +94,10 @@ pub(super) fn parse_key_list(raw: &str) -> Result<Vec<String>, ApplicationError>
}
}
Value::Null => continue,
_ => {
return Err(ApplicationError::ValidationError(
"Custom exclude list must be an array of strings, an object, or a string."
.to_string(),
))
}
_ => return Err(ApplicationError::ValidationError(
"Custom exclude list must be an array of strings, an object, or a string."
.to_string(),
)),
}
}
Ok(keys.into_iter().collect())

View file

@ -0,0 +1,279 @@
#![cfg(target_os = "ios")]
use std::cell::RefCell;
use objc2::ffi::{OBJC_ASSOCIATION_RETAIN_NONATOMIC, objc_setAssociatedObject};
use objc2::rc::{Allocated, Retained};
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send};
use objc2_foundation::{
NSArray, NSError, NSFileManager, NSObject, NSObjectProtocol, NSString, NSURL, ns_string,
};
use objc2_ui_kit::{UIDocumentPickerDelegate, UIDocumentPickerViewController};
use objc2_uniform_type_identifiers::UTType;
use tauri::WebviewWindow;
use tokio::sync::oneshot;
use crate::domain::errors::DomainError;
use crate::infrastructure::ios_ui::resolve_presenting_view_controller;
pub struct PickedUrl {
pub url: Retained<NSURL>,
pub file_name: String,
}
pub enum PickZipResult {
Cancelled,
Picked(PickedUrl),
}
enum PickOutcome {
Cancelled,
Picked(PickedUrl),
Failed(String),
}
struct DocumentPickerDelegateIvars {
sender: RefCell<Option<oneshot::Sender<PickOutcome>>>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = DocumentPickerDelegateIvars]
struct DocumentPickerDelegate;
impl DocumentPickerDelegate {
#[unsafe(method_id(init))]
fn init(this: Allocated<Self>) -> Retained<Self> {
let this = this.set_ivars(DocumentPickerDelegateIvars {
sender: RefCell::new(None),
});
unsafe { msg_send![super(this), init] }
}
}
unsafe impl NSObjectProtocol for DocumentPickerDelegate {}
#[allow(non_snake_case)]
unsafe impl UIDocumentPickerDelegate for DocumentPickerDelegate {
#[unsafe(method(documentPicker:didPickDocumentsAtURLs:))]
fn documentPicker_didPickDocumentsAtURLs(
&self,
_controller: &UIDocumentPickerViewController,
urls: &NSArray<NSURL>,
) {
let Some(url) = urls.firstObject() else {
self.send(PickOutcome::Failed(
"Document picker did not return any selected URLs".to_string(),
));
return;
};
self.send(Self::picked_url_to_outcome(&url));
}
#[unsafe(method(documentPickerWasCancelled:))]
fn documentPickerWasCancelled(&self, _controller: &UIDocumentPickerViewController) {
self.send(PickOutcome::Cancelled);
}
#[unsafe(method(documentPicker:didPickDocumentAtURL:))]
fn documentPicker_didPickDocumentAtURL(
&self,
_controller: &UIDocumentPickerViewController,
url: &NSURL,
) {
self.send(Self::picked_url_to_outcome(url));
}
}
);
impl DocumentPickerDelegate {
fn new(mtm: MainThreadMarker, sender: oneshot::Sender<PickOutcome>) -> Retained<Self> {
let this = Self::alloc(mtm);
let this = this.set_ivars(DocumentPickerDelegateIvars {
sender: RefCell::new(Some(sender)),
});
unsafe { msg_send![super(this), init] }
}
fn send(&self, outcome: PickOutcome) {
let sender = self.ivars().sender.borrow_mut().take();
if let Some(sender) = sender {
let _ = sender.send(outcome);
}
}
fn picked_url_to_outcome(url: &NSURL) -> PickOutcome {
if !url.isFileURL() {
return PickOutcome::Failed("Picked archive URL is not a file URL".to_string());
}
let file_name = url
.lastPathComponent()
.map(|value| value.to_string())
.unwrap_or_default();
PickOutcome::Picked(PickedUrl {
url: Retained::from(url),
file_name,
})
}
}
static DOCUMENT_PICKER_DELEGATE_KEY: u8 = 0;
unsafe fn retain_delegate(
controller: &UIDocumentPickerViewController,
delegate: &DocumentPickerDelegate,
) {
unsafe {
objc_setAssociatedObject(
controller as *const _ as *mut AnyObject,
(&DOCUMENT_PICKER_DELEGATE_KEY as *const u8).cast(),
delegate as *const _ as *mut AnyObject,
OBJC_ASSOCIATION_RETAIN_NONATOMIC,
);
}
}
pub async fn pick_zip_archive(window: &WebviewWindow) -> Result<PickZipResult, DomainError> {
let (sender, receiver) = oneshot::channel::<PickOutcome>();
window
.run_on_main_thread(move || {
let mut sender = Some(sender);
let send_failure = |sender: &mut Option<oneshot::Sender<PickOutcome>>,
message: String| {
if let Some(sender) = sender.take() {
let _ = sender.send(PickOutcome::Failed(message));
}
};
let presenting = match resolve_presenting_view_controller() {
Ok(presenting) => presenting,
Err(error) => {
send_failure(&mut sender, error.to_string());
return;
}
};
let Some(mtm) = MainThreadMarker::new() else {
send_failure(
&mut sender,
"Document picker must be presented on the main thread".to_string(),
);
return;
};
let delegate_sender = sender
.take()
.expect("Document picker sender should be set before delegate creation");
let delegate = DocumentPickerDelegate::new(mtm, delegate_sender);
let delegate_protocol_object = ProtocolObject::from_ref(&*delegate);
let zip_archive = match UTType::typeWithIdentifier(ns_string!("public.zip-archive")) {
Some(value) => value,
None => {
send_failure(
&mut sender,
"UTType public.zip-archive is unavailable".to_string(),
);
return;
}
};
let pkware_zip = UTType::typeWithIdentifier(ns_string!("com.pkware.zip-archive"))
.unwrap_or_else(|| zip_archive.clone());
let content_types: Retained<NSArray<UTType>> =
NSArray::from_retained_slice(&[zip_archive, pkware_zip]);
let picker = UIDocumentPickerViewController::initForOpeningContentTypes_asCopy(
UIDocumentPickerViewController::alloc(mtm),
&content_types,
true,
);
picker.setAllowsMultipleSelection(false);
picker.setShouldShowFileExtensions(true);
picker.setDelegate(Some(&delegate_protocol_object));
unsafe { retain_delegate(&picker, &*delegate) };
if let Some(popover) = picker.popoverPresentationController() {
if let Some(source_view) = presenting.view() {
popover.setSourceView(Some(&source_view));
popover.setSourceRect(source_view.bounds());
}
}
presenting.presentViewController_animated_completion(&picker, true, None);
})
.map_err(|error| DomainError::InternalError(error.to_string()))?;
let outcome = receiver.await.map_err(|_| {
DomainError::InternalError("Document picker was dismissed unexpectedly".to_string())
})?;
match outcome {
PickOutcome::Cancelled => Ok(PickZipResult::Cancelled),
PickOutcome::Picked(picked) => Ok(PickZipResult::Picked(picked)),
PickOutcome::Failed(message) => Err(DomainError::InternalError(message)),
}
}
struct SecurityScopedAccess<'a> {
url: &'a NSURL,
active: bool,
}
impl<'a> SecurityScopedAccess<'a> {
fn start(url: &'a NSURL) -> Self {
let active = unsafe { url.startAccessingSecurityScopedResource() };
Self { url, active }
}
}
impl Drop for SecurityScopedAccess<'_> {
fn drop(&mut self) {
if self.active {
unsafe { self.url.stopAccessingSecurityScopedResource() };
}
}
}
pub fn copy_picked_url_to_path(
source_url: &NSURL,
target_path: &std::path::Path,
) -> Result<(), DomainError> {
if !source_url.isFileURL() {
return Err(DomainError::InvalidData(format!(
"Picked URL is not a file URL: {}",
source_url
.absoluteString()
.map(|value| value.to_string())
.unwrap_or_default()
)));
}
let _security_scope = SecurityScopedAccess::start(source_url);
let target_path_string = target_path.to_string_lossy().to_string();
let ns_target_path = NSString::from_str(&target_path_string);
let target_url = NSURL::fileURLWithPath(&ns_target_path);
let file_manager = NSFileManager::defaultManager();
file_manager
.copyItemAtURL_toURL_error(source_url, &target_url)
.map_err(|error: Retained<NSError>| {
DomainError::InternalError(format!(
"Failed to copy selected archive to staging directory: {}",
error.localizedDescription()
))
})?;
Ok(())
}

View file

@ -0,0 +1,128 @@
#![cfg(target_os = "ios")]
use block2::RcBlock;
use objc2::rc::Retained;
use objc2::runtime::Bool;
use objc2::{MainThreadMarker, MainThreadOnly};
use objc2_foundation::{NSArray, NSError, NSString, NSURL};
use objc2_ui_kit::{UIActivityType, UIActivityViewController};
use tauri::WebviewWindow;
use tokio::sync::oneshot;
use crate::domain::errors::DomainError;
use crate::infrastructure::ios_ui::resolve_presenting_view_controller;
#[derive(Debug, Clone)]
pub struct ShareResult {
pub completed: bool,
pub activity: Option<String>,
}
enum ShareOutcome {
Finished(ShareResult),
Failed(String),
}
pub async fn share_file(
window: &WebviewWindow,
file_path: &std::path::Path,
) -> Result<ShareResult, DomainError> {
if !file_path.is_file() {
return Err(DomainError::NotFound(format!(
"Export archive file not found: {}",
file_path.display()
)));
}
let file_path_string = file_path.to_string_lossy().to_string();
let (sender, receiver) = oneshot::channel::<ShareOutcome>();
window
.run_on_main_thread(move || {
let mut sender = Some(sender);
let send_failure = |sender: &mut Option<oneshot::Sender<ShareOutcome>>,
message: String| {
if let Some(sender) = sender.take() {
let _ = sender.send(ShareOutcome::Failed(message));
}
};
let presenting = match resolve_presenting_view_controller() {
Ok(presenting) => presenting,
Err(error) => {
send_failure(&mut sender, error.to_string());
return;
}
};
let Some(mtm) = MainThreadMarker::new() else {
send_failure(
&mut sender,
"Share sheet must be presented on the main thread".to_string(),
);
return;
};
let ns_path = NSString::from_str(&file_path_string);
let url = NSURL::fileURLWithPath(&ns_path);
let activity_items = NSArray::from_retained_slice(&[Retained::from(url)]);
let controller = unsafe {
UIActivityViewController::initWithActivityItems_applicationActivities(
UIActivityViewController::alloc(mtm),
&activity_items,
None,
)
};
let completion_sender = std::cell::RefCell::new(sender.take());
let completion_block: RcBlock<
dyn Fn(*mut UIActivityType, Bool, *mut NSArray, *mut NSError),
> = RcBlock::new(
move |activity_type: *mut UIActivityType,
completed: Bool,
_items: *mut NSArray,
error: *mut NSError| {
let sender = completion_sender.borrow_mut().take();
let Some(sender) = sender else {
return;
};
if let Some(error) = unsafe { error.as_ref() } {
let _ = sender.send(ShareOutcome::Failed(
error.localizedDescription().to_string(),
));
return;
}
let activity = unsafe { activity_type.as_ref() }.map(|value| value.to_string());
let _ = sender.send(ShareOutcome::Finished(ShareResult {
completed: completed.as_bool(),
activity,
}));
},
);
unsafe { controller.setCompletionWithItemsHandler(RcBlock::as_ptr(&completion_block)) };
if let Some(popover) = controller.popoverPresentationController() {
if let Some(source_view) = presenting.view() {
popover.setSourceView(Some(&source_view));
popover.setSourceRect(source_view.bounds());
}
}
presenting.presentViewController_animated_completion(&controller, true, None);
})
.map_err(|error| DomainError::InternalError(error.to_string()))?;
let outcome = receiver.await.map_err(|_| {
DomainError::InternalError("Share sheet was dismissed unexpectedly".to_string())
})?;
match outcome {
ShareOutcome::Finished(result) => Ok(result),
ShareOutcome::Failed(message) => Err(DomainError::InternalError(message)),
}
}

View file

@ -0,0 +1,64 @@
#![cfg(target_os = "ios")]
use std::ffi::CStr;
use objc2::msg_send;
use objc2::rc::Retained;
use objc2::runtime::{AnyClass, AnyObject};
use objc2_foundation::NSArray;
use crate::domain::errors::DomainError;
use objc2_ui_kit::{UIViewController, UIWindow};
/// Resolve the top-most view controller for presenting modal UI.
///
/// Must be called on the main thread.
pub fn resolve_presenting_view_controller() -> Result<Retained<UIViewController>, DomainError> {
let ui_application =
AnyClass::get(unsafe { CStr::from_bytes_with_nul_unchecked(b"UIApplication\0") })
.ok_or_else(|| {
DomainError::InternalError("UIApplication class is unavailable".to_string())
})?;
let app: Option<Retained<AnyObject>> = unsafe { msg_send![ui_application, sharedApplication] };
let app = app.ok_or_else(|| {
DomainError::InternalError("UIApplication.sharedApplication returned null".to_string())
})?;
let windows: Option<Retained<NSArray<UIWindow>>> = unsafe { msg_send![&*app, windows] };
let windows = windows.ok_or_else(|| {
DomainError::InternalError("UIApplication.windows returned null".to_string())
})?;
let windows = windows.to_vec();
if windows.is_empty() {
return Err(DomainError::InternalError(
"UIApplication.windows returned an empty list".to_string(),
));
}
let mut chosen_window = None;
for window in windows {
if chosen_window.is_none() {
chosen_window = Some(window.clone());
}
if window.isKeyWindow() {
chosen_window = Some(window);
break;
}
}
let ui_window = chosen_window.expect("windows list is non-empty");
let mut current = ui_window.rootViewController().ok_or_else(|| {
DomainError::InternalError("UIWindow.rootViewController returned null".to_string())
})?;
while let Some(next) = current.presentedViewController() {
current = next;
}
Ok(current)
}

View file

@ -3,6 +3,12 @@ pub mod apis;
pub mod assets;
pub mod http_client;
#[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;
#[cfg(target_os = "ios")]
pub mod ios_webview;
pub mod lan_sync;
pub mod logging;

View file

@ -1,4 +1,5 @@
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs;
use std::fs::File;
use std::io::BufReader;
@ -53,6 +54,20 @@ pub fn extract_to_normalized_root_streaming(
processed_entries = processed_entries.saturating_add(1);
if matches!(
sanitized_path.components().next(),
Some(std::path::Component::Normal(component))
if component == OsStr::new("__MACOSX")
) {
maybe_report_extraction_progress(
processed_entries,
total_entries,
&mut last_reported_percent,
report_progress,
);
continue;
}
let Some(rel_components) = components_after_prefix(&sanitized_path, &layout.source_prefix)
else {
maybe_report_extraction_progress(

View file

@ -1,4 +1,5 @@
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
@ -92,6 +93,14 @@ pub fn scan_archive_layout(archive_path: &Path) -> Result<LayoutMeta, DomainErro
)));
}
if matches!(
sanitized_path.components().next(),
Some(std::path::Component::Normal(component))
if component == OsStr::new("__MACOSX")
) {
continue;
}
let components = path_components(&sanitized_path);
if components.is_empty() {
continue;
@ -394,6 +403,50 @@ mod tests {
crate::infrastructure::persistence::data_archive::shared::cleanup_directory_sync(&root);
}
#[test]
fn ignores_macosx_resource_forks_for_data_root_layout() {
let root =
std::env::temp_dir().join(format!("tauritavern-layout-{}", rand::random::<u64>()));
let zip_path = root.join("fixture.zip");
fs::create_dir_all(&root).expect("create root");
write_zip(
&zip_path,
&[
("data/default-user/characters/a.json", b"{}"),
("__MACOSX/data/default-user/characters/._a.json", b"junk"),
],
);
let layout = scan_archive_layout(&zip_path).expect("scan layout");
assert_eq!(layout.kind, LayoutKind::DataRoot);
assert_eq!(layout.source_prefix, PathBuf::from("data"));
crate::infrastructure::persistence::data_archive::shared::cleanup_directory_sync(&root);
}
#[test]
fn ignores_macosx_resource_forks_for_user_handle_layout() {
let root =
std::env::temp_dir().join(format!("tauritavern-layout-{}", rand::random::<u64>()));
let zip_path = root.join("fixture.zip");
fs::create_dir_all(&root).expect("create root");
write_zip(
&zip_path,
&[
("default-user/characters/a.json", b"{}"),
("__MACOSX/default-user/characters/._a.json", b"junk"),
],
);
let layout = scan_archive_layout(&zip_path).expect("scan layout");
assert_eq!(layout.kind, LayoutKind::UserHandleRoot);
assert!(layout.source_prefix.as_os_str().is_empty());
crate::infrastructure::persistence::data_archive::shared::cleanup_directory_sync(&root);
}
#[test]
fn detects_user_root_layout() {
let root =

View file

@ -177,9 +177,9 @@ impl CharacterRepository for FileCharacterRepository {
serde_json::Value::String(new_name.to_string()),
);
let data_value = card_object.entry("data").or_insert_with(|| {
serde_json::Value::Object(serde_json::Map::new())
});
let data_value = card_object
.entry("data")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let data_object = data_value.as_object_mut().ok_or_else(|| {
DomainError::InvalidData("Character card data field is invalid".to_string())

View file

@ -0,0 +1,184 @@
#![cfg(target_os = "ios")]
use std::fs;
use std::path::PathBuf;
use serde::Serialize;
use tauri::{AppHandle, WebviewWindow};
use crate::domain::errors::DomainError;
use crate::infrastructure::ios_document_picker::{
PickZipResult, copy_picked_url_to_path, pick_zip_archive,
};
use crate::infrastructure::ios_share_sheet::{ShareResult, share_file};
use crate::infrastructure::paths::resolve_runtime_paths;
use crate::infrastructure::persistence::data_archive_jobs::{
cleanup_export_data_archive as cleanup_export_data_archive_impl,
get_data_archive_job_status as get_data_archive_job_status_impl,
start_import_data_archive_job as start_import_data_archive_job_impl,
};
use crate::presentation::commands::helpers::{log_command, map_command_error};
use crate::presentation::errors::CommandError;
#[derive(Debug, Clone, Serialize)]
pub struct IosImportArchiveResponse {
pub cancelled: bool,
pub job_id: Option<String>,
pub file_name: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IosShareExportArchiveResponse {
pub completed: bool,
pub activity: Option<String>,
pub cleanup_error: Option<String>,
}
#[tauri::command]
pub async fn ios_import_data_archive_from_picker(
app: AppHandle,
window: WebviewWindow,
) -> Result<IosImportArchiveResponse, CommandError> {
log_command("ios_import_data_archive_from_picker");
let picked = match pick_zip_archive(&window)
.await
.map_err(map_command_error("Failed to present iOS document picker"))?
{
PickZipResult::Cancelled => {
return Ok(IosImportArchiveResponse {
cancelled: true,
job_id: None,
file_name: None,
});
}
PickZipResult::Picked(picked) => picked,
};
let app_handle = app.clone();
let picked_url = picked.url.clone();
let picked_file_name = picked.file_name.clone();
let job_id = tauri::async_runtime::spawn_blocking(move || -> Result<String, DomainError> {
let target_path = prepare_incoming_import_archive_path(&app_handle)?;
let _cleanup_target = CleanupTempFile::new(target_path.clone());
copy_picked_url_to_path(&picked_url, &target_path)?;
start_import_data_archive_job_impl(&app_handle, &target_path, true)
})
.await
.map_err(|error| {
CommandError::InternalServerError(format!(
"Failed to join iOS import staging task: {}",
error
))
})?
.map_err(map_command_error("Failed to start data archive import"))?;
Ok(IosImportArchiveResponse {
cancelled: false,
job_id: Some(job_id),
file_name: Some(picked_file_name),
})
}
struct CleanupTempFile {
path: PathBuf,
}
impl CleanupTempFile {
fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl Drop for CleanupTempFile {
fn drop(&mut self) {
if let Err(error) = fs::remove_file(&self.path) {
if error.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
"Failed to cleanup temporary file {}: {}",
self.path.display(),
error
);
}
}
}
}
fn prepare_incoming_import_archive_path(app: &AppHandle) -> Result<PathBuf, DomainError> {
let runtime_paths = resolve_runtime_paths(app).map_err(|error| {
DomainError::InternalError(format!("Failed to resolve runtime paths: {}", error))
})?;
let incoming_dir = runtime_paths.archive_imports_root.join("incoming");
fs::create_dir_all(&incoming_dir).map_err(|error| {
DomainError::InternalError(format!(
"Failed to create import staging directory {}: {}",
incoming_dir.display(),
error
))
})?;
Ok(incoming_dir.join(format!(
"tauritavern-import-{}.zip",
uuid::Uuid::new_v4().simple()
)))
}
#[tauri::command]
fn resolve_completed_export_archive_path(job_id: &str) -> Result<PathBuf, DomainError> {
let status = get_data_archive_job_status_impl(job_id)?;
if status.kind != "export" {
return Err(DomainError::InvalidData("Invalid export job".to_string()));
}
if status.state != "completed" {
return Err(DomainError::InvalidData(format!(
"Export job is not completed yet: {}",
job_id
)));
}
let archive_path = status
.result
.and_then(|result| result.archive_path)
.ok_or_else(|| {
DomainError::InvalidData(format!(
"Export archive path is missing for job: {}",
job_id
))
})?;
Ok(PathBuf::from(archive_path))
}
#[tauri::command]
pub async fn ios_share_export_data_archive(
window: WebviewWindow,
job_id: String,
) -> Result<IosShareExportArchiveResponse, CommandError> {
log_command("ios_share_export_data_archive");
let job_id = job_id.trim().to_string();
if job_id.is_empty() {
return Err(CommandError::BadRequest("Missing job_id".to_string()));
}
let archive_path = resolve_completed_export_archive_path(&job_id)
.map_err(map_command_error("Failed to resolve export archive path"))?;
let share_result: ShareResult = share_file(&window, &archive_path)
.await
.map_err(map_command_error("Failed to present iOS share sheet"))?;
let cleanup_error = cleanup_export_data_archive_impl(&job_id)
.err()
.map(|error| error.to_string());
Ok(IosShareExportArchiveResponse {
completed: share_result.completed,
activity: share_result.activity,
cleanup_error,
})
}

View file

@ -13,6 +13,8 @@ pub mod file_commands;
pub mod group_commands;
pub mod helpers;
pub mod image_commands;
#[cfg(target_os = "ios")]
pub mod ios_file_bridge_commands;
pub mod lan_sync_commands;
pub mod preset_commands;
pub mod quick_reply_commands;

View file

@ -102,6 +102,11 @@ pub fn invoke_handler() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Sen
super::data_archive_commands::cancel_data_archive_job,
super::data_archive_commands::save_export_data_archive,
super::data_archive_commands::cleanup_export_data_archive,
// iOS file bridge commands
#[cfg(target_os = "ios")]
super::ios_file_bridge_commands::ios_import_data_archive_from_picker,
#[cfg(target_os = "ios")]
super::ios_file_bridge_commands::ios_share_export_data_archive,
// Extension commands
super::extension_commands::get_extensions,
super::extension_commands::install_extension,

View file

@ -21,12 +21,7 @@ pub fn dispatch_dev_web_resource_request<R: tauri::Runtime>(
let path = request.uri().path();
if path.starts_with(THIRD_PARTY_EXTENSION_ROUTE_PREFIX) {
let dirs = app_handle.state::<ThirdPartyExtensionDirs>();
handle_third_party_asset_web_request(
&dirs.local_dir,
&dirs.global_dir,
request,
response,
);
handle_third_party_asset_web_request(&dirs.local_dir, &dirs.global_dir, request, response);
return;
}

View file

@ -1,5 +1,7 @@
import { renderExtensionTemplateAsync } from '../../extensions.js';
import { t } from '../../i18n.js';
import { Popup } from '../../popup.js';
import { isAndroidRuntime, isIosRuntime } from '../../util/mobile-runtime.js';
const MODULE_NAME = 'data-migration';
const JOB_POLL_INTERVAL_MS = 1200;
@ -55,14 +57,6 @@ function requireJobId(payload, errorMessage) {
return payload.job_id.trim();
}
function isAndroidRuntime() {
if (typeof navigator === 'undefined' || typeof navigator.userAgent !== 'string') {
return false;
}
return /android/i.test(navigator.userAgent);
}
async function requestImportJob(url, init) {
const response = await fetch(url, init);
if (!response.ok) {
@ -111,6 +105,27 @@ async function pickAndroidImportArchive() {
return contentUri;
}
async function startImportJobFromIosPicker() {
const response = await fetch('/api/extensions/data-migration/import/ios', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(await readFailureMessage(response));
}
const payload = await response.json();
if (payload?.cancelled) {
return null;
}
return requireJobId(payload, t`Import job id is missing`);
}
async function saveExportArchive(jobId) {
if (isAndroidRuntime()) {
const response = await fetch('/api/extensions/data-migration/export/android/save', {
@ -131,6 +146,27 @@ async function saveExportArchive(jobId) {
};
}
if (isIosRuntime()) {
const response = await fetch('/api/extensions/data-migration/export/ios/share', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ job_id: jobId }),
});
if (!response.ok) {
throw new Error(await readFailureMessage(response));
}
const payload = await response.json();
return {
mode: 'ios-native-share',
completed: Boolean(payload?.completed),
activity: payload?.activity ? String(payload.activity) : null,
cleanupError: payload?.cleanup_error ? String(payload.cleanup_error) : null,
};
}
const response = await fetch('/api/extensions/data-migration/export/save', {
method: 'POST',
headers: {
@ -177,7 +213,7 @@ function refreshExportActions() {
}
const savedPath = String(jobState.lastExportSavedPath || '').trim();
const visible = Boolean(savedPath) && !isAndroidRuntime();
const visible = Boolean(savedPath) && !isAndroidRuntime() && !isIosRuntime();
actions.toggle(visible);
$('#data_migration_reveal_export_button').prop('disabled', !visible);
@ -246,17 +282,28 @@ async function onRevealExportClick() {
}
async function onImportButtonClick() {
if (hasActiveJob()) {
toastr.warning(t`A migration job is already running`);
return;
}
try {
if (hasActiveJob()) {
toastr.warning(t`A migration job is already running`);
return;
}
if (isAndroidRuntime()) {
await onAndroidImportButtonClick();
return;
}
if (isAndroidRuntime()) {
await onAndroidImportButtonClick();
return;
}
$('#data_migration_import_input').trigger('click');
if (isIosRuntime()) {
await onIosImportButtonClick();
return;
}
$('#data_migration_import_input').trigger('click');
} catch (error) {
const failureMessage = normalizeCaughtError(error);
toastr.error(failureMessage, t`Data import failed`);
setStatusText(failureMessage);
}
}
async function startExportJob() {
@ -398,6 +445,15 @@ async function runMigrationJob(kind, startJob) {
markJobStarting();
preparingProgress?.start();
const jobId = await startJob();
if (!jobId) {
if (kind === 'import') {
toastr.info(t`Import cancelled`);
setStatusText(t`Import cancelled`);
return;
}
throw new Error(t`Migration job did not return a job id`);
}
preparingProgress?.complete();
startJobTracking(jobId);
@ -422,6 +478,22 @@ async function runMigrationJob(kind, startJob) {
}, 800);
} else {
const saveResult = await saveExportArchive(jobId);
if (saveResult.mode === 'ios-native-share') {
if (saveResult.cleanupError) {
toastr.warning(saveResult.cleanupError, t`Export cleanup failed`);
}
if (saveResult.completed) {
toastr.success(t`Data archive is ready to share/save`, t`Export completed`, { timeOut: 8000 });
setStatusText(t`Export completed`);
} else {
toastr.info(t`Sharing cancelled`, t`Export cancelled`);
setStatusText(t`Export cancelled`);
}
return;
}
if (saveResult.mode === 'mobile-native') {
void cleanupExportArchive(jobId).catch((error) => {
console.warn('Failed to cleanup export archive:', error);
@ -483,10 +555,15 @@ async function onAndroidImportButtonClick() {
});
}
async function onIosImportButtonClick() {
await runConfirmedImport(() => startImportJobFromIosPicker());
}
async function runConfirmedImport(startJob) {
const confirmed = window.confirm(
t`Importing will merge into the current local data directory (same-path files will be overwritten). Continue?`,
);
const prompt = t`Importing will merge into the current local data directory (same-path files will be overwritten). Continue?`;
const confirmed = isIosRuntime()
? await Popup.show.confirm(t`Confirm data import`, prompt)
: window.confirm(prompt);
if (!confirmed) {
return;
}

View file

@ -0,0 +1,24 @@
export function isAndroidRuntime() {
return typeof navigator !== 'undefined'
&& typeof navigator.userAgent === 'string'
&& /android/i.test(navigator.userAgent);
}
export function isIosRuntime() {
if (typeof navigator === 'undefined') {
return false;
}
const userAgent = typeof navigator.userAgent === 'string' ? navigator.userAgent : '';
if (/iphone|ipad|ipod/i.test(userAgent)) {
return true;
}
const touchPoints = Number(navigator.maxTouchPoints || 0);
if (touchPoints <= 1) {
return false;
}
const platform = typeof navigator.platform === 'string' ? navigator.platform : '';
return platform === 'MacIntel' || /macintosh/i.test(userAgent);
}

View file

@ -66,6 +66,8 @@ export {};
* | 'import_group_chat_payload'
* | 'import_world_info'
* | 'install_extension'
* | 'ios_import_data_archive_from_picker'
* | 'ios_share_export_data_archive'
* | 'list_chat_backups'
* | 'list_recent_chat_summaries'
* | 'list_recent_group_chat_summaries'

View file

@ -1,9 +1,13 @@
import { isIosRuntime } from '../../../scripts/util/mobile-runtime.js';
function parseJobId(value) {
const jobId = String(value || '').trim();
return jobId || '';
}
export function registerExtensionRoutes(router, context, { jsonResponse }) {
const iosRuntime = isIosRuntime();
async function startImportJobFromFileInfo(fileInfo) {
if (!fileInfo?.filePath) {
const reason = fileInfo?.error ? `: ${fileInfo.error}` : '';
@ -164,6 +168,19 @@ export function registerExtensionRoutes(router, context, { jsonResponse }) {
});
});
if (iosRuntime) {
router.post('/api/extensions/data-migration/import/ios', async () => {
const result = await context.safeInvoke('ios_import_data_archive_from_picker');
return jsonResponse({
ok: true,
cancelled: Boolean(result?.cancelled),
job_id: result?.job_id ? String(result.job_id) : '',
file_name: result?.file_name ? String(result.file_name) : '',
});
});
}
router.post('/api/extensions/data-migration/export', async () => {
const jobId = parseJobId(await context.safeInvoke('start_export_data_archive'));
if (!jobId) {
@ -223,6 +240,26 @@ export function registerExtensionRoutes(router, context, { jsonResponse }) {
});
});
if (iosRuntime) {
router.post('/api/extensions/data-migration/export/ios/share', async ({ body }) => {
const jobId = parseJobId(body?.job_id);
if (!jobId) {
return jsonResponse({ error: 'Missing job id' }, 400);
}
const result = await context.safeInvoke('ios_share_export_data_archive', {
job_id: jobId,
});
return jsonResponse({
ok: true,
completed: Boolean(result?.completed),
activity: result?.activity ? String(result.activity) : null,
cleanup_error: result?.cleanup_error ? String(result.cleanup_error) : null,
});
});
}
router.get('/api/extensions/data-migration/job', async ({ url }) => {
const jobId = parseJobId(url?.searchParams?.get('id'));
if (!jobId) {