mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
fix (iOS): Fixed import/export issues with the data-migration extension on iOS.
This commit is contained in:
parent
276de46878
commit
46ed58d51d
21 changed files with 994 additions and 55 deletions
|
|
@ -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 解耦,修复底部死区并收敛契约
|
||||
|
||||
## 项目概述
|
||||
|
||||
|
|
|
|||
|
|
@ -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
15
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
279
src-tauri/src/infrastructure/ios_document_picker.rs
Normal file
279
src-tauri/src/infrastructure/ios_document_picker.rs
Normal 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(())
|
||||
}
|
||||
128
src-tauri/src/infrastructure/ios_share_sheet.rs
Normal file
128
src-tauri/src/infrastructure/ios_share_sheet.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
64
src-tauri/src/infrastructure/ios_ui.rs
Normal file
64
src-tauri/src/infrastructure/ios_ui.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
184
src-tauri/src/presentation/commands/ios_file_bridge_commands.rs
Normal file
184
src-tauri/src/presentation/commands/ios_file_bridge_commands.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
24
src/scripts/util/mobile-runtime.js
Normal file
24
src/scripts/util/mobile-runtime.js
Normal 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);
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue