TauriTavern/docs/iOSDevelopment.md
Darkatse 44d2074fef ci(ios): build release iOS apps with Xcode 27.1
The canary and stable iOS builds ran on macos-latest, whose Xcode 26.6
links the iOS 26.5 SDK. iPhone Duo runs an app linked that way in a
compatibility window at a legacy iPhone size, without the vertical bar,
so the drawer bar never appears. These builds now run on the xcode-27
runner and select Xcode 27.1, whose SDK has the bar APIs; the image
defaults to 27.0. The deployment target stays at iOS 15.0.

Validation: both workflows parse; the first canary run confirms the
runner and the TestFlight upload.
2026-10-24 23:58:20 -04:00

176 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TauriTavern iOS 端开发说明
本文档记录 iOS 端已经踩过的关键问题、根因与已落地方案。
补充:iOS/iPadOS 的 **分发 Policy(profile + capabilities snapshot)** 属于“合规裁剪/能力分级”问题域,其当前实现快照与维护约束已收敛到 `docs/CurrentState/iOSPolicy.md`,本文件仍聚焦 WKWebView 行为差异与 iOS-only 桥接。
当前系统支持边界:
| iOS/iPadOS | `structuredClone` | Element Fullscreen | 支持级别 |
| --- | --- | --- | --- |
| 15.0–15.3 | 上游 vendored polyfill | 开启 | 有限支持 |
| 15.4–15.x | WebKit 原生实现 | 开启 | 有限支持 |
| 16.0–16.3 | WebKit 原生实现 | 开启 | 有限支持 |
| 16.4+ | WebKit 原生实现 | 开启 | 完整支持 |
`src/index.html` 已在 `init.js` 前加载 SillyTavern 1.18.0 的 `lib/structured-clone/monkey-patch.js`。它只在全局能力缺失时安装,15.4+ 不替换 WebKit 原生函数。
## 1. WKWebView 内容视口
`infrastructure/ios_webview.rs::configure_main_wkwebview()` 在主窗口 build 后安装 `platform/ios_window_layout.rs` 的窗口宿主。WKWebView 的上、左、右约束到 safeAreaLayoutGuide,底边约束到 keyboardLayoutGuide.topAnchor;内部 UIScrollView 保持 `.never` 与零 inset,避免同一份遮挡再扣一次。iOS 没有沉浸模式。职责与背景链路见 [CurrentState/MobileStyleAdaptation.md](CurrentState/MobileStyleAdaptation.md)。
排查灰带或双重缩小时,对照 WKWebView frame、`innerHeight`/`visualViewport` 与键盘边界:窗口与内容视口的差值应恰好等于安全区与停靠键盘。
附属栏移除、下滑收键盘手势与拖动期间的高度保持见同一文档的 iOS 一节。排查收键盘卡顿时数 `innerHeight` 的变化次数:一次下滑收起只应有 1 次 resize;逐帧变化说明 WebView 高度在跟随 guide,WebKit 会逐帧整文档重建样式(`WebPage::viewportConfigurationChanged → updateSizeForCSSDefaultViewportUnits → Style::Scope::didChangeStyleSheetEnvironment`)。
### 1.1 UIScene 生命周期
iOS 27 SDK 要求 UIScene 生命周期。Tao 只要看到 `UIApplicationSceneManifest` 就启用它,与多窗口无关。`gen/apple/project.yml` 与已提交的 `Info.plist` 保留 manifest 并声明 `UIApplicationSupportsMultipleScenes = false`,应用只有一个主窗口;Tao 动态返回带 `TaoSceneDelegate` 的配置,宿主不监听 `SceneRequested`,也不声明 `UISceneConfigurations`。
scene 生命周期相关的改动以 release 构建真机冷启动验收:#337 的崩溃只在 release 构建出现。
### 1.2 Xcode 27 构建
发布用的 iOS 构建(canary 与 stable 的 `mobile` job)运行在 `xcode-27` runner 上,并选用 Xcode 27.1。应用的行为跟随链接的 SDK:用 iOS 26 SDK 链接的应用在 iPhone Duo 上以兼容模式运行,画布为旧 iPhone 尺寸,没有竖栏。
Xcode 27 的优化构建会把非 `public` 的 `@_cdecl` 函数降为本地符号,Rust 因此链接不到 Swift 入口。swift-rs 1.0.9、Tauri 2.12.3 与当前锁定的官方插件已把这些入口声明为 `public`;引入新的带 Swift 包的插件时,确认它的 `init_plugin_*` 也是 `public`。
`swift-rs` 按所在 crate 的调试设置选择 Swift 优化级别。Tauri 与带 Swift 包的插件必须以同一模式构建:dev profile 对所有依赖关闭调试信息,不为其中个别 crate 单独开启。模式不一致时,真机在 `registerPlugin` 以 `swift_deletedMethodError` 中止,模拟器不会暴露。
### 1.3 iPhone Duo
抽屉栏的规则见 [CurrentState/MobileStyleAdaptation.md](CurrentState/MobileStyleAdaptation.md) 的抽屉栏一节。模拟器验证使用 Xcode 27.1 与 iOS 27.1 runtime:
- 该 runtime 目前只提供 iPhone Duo,非 Duo 设备上的 27.1 行为无法在模拟器上验证。
- 开合只能在 Device Hub 中切换。不要用 `simctl io screenConfig ... power` 模拟:它不改变形态,还会让 SpringBoard 崩溃。
- 外屏可以用 `requestGeometryUpdateWithPreferences:` 旋转,内屏拒绝程序旋转。
- 截图:外屏 `xcrun simctl io <udid> screenshot --display=primary`,内屏 `--display=primary-1`;加 `--mask=black` 才能看到显示圆角的裁切,外屏四角的圆角并不相同。
## 2. 文件导入与导出
iOS 是[文件传输](CurrentState/FileTransfer.md)的一个平台实现:
- 选取:Document Picker 以 `asCopy` 生成副本,宿主把副本移动进暂存;
- 交付:使用 Share Sheet,建议文件名由 `NSItemProvider` 提供。
UIKit 界面在主线程上展示;在 iPad 上,popover 必须指定 sourceView/sourceRect。实现见 `platform/ios_document_picker.rs` 与 `platform/ios_share_sheet.rs`。
## 3. macOS 元数据导致的“布局歧义”问题
部分 zip(尤其是从 macOS Finder 打包/转发)会携带 `__MACOSX/**` 资源分叉条目;它会在布局探测阶段制造“存在多个候选根”的假象,触发错误:
- `Invalid data: Archive layout is ambiguous`
当前实现会在 **布局扫描** 与 **解压归一化** 时忽略 `__MACOSX` 条目,保证这类 zip 可正常导入:
- `src-tauri/crates/tt-adapter-archive/src/data_archive/import/layout.rs`
- `src-tauri/crates/tt-adapter-archive/src/data_archive/import/extract.rs`
## 4. WKWebView Element Fullscreen
- Element fullscreen 由 Wry 默认开启(iOS 15.4+ 用公开的 `WKPreferences.elementFullscreenEnabled`,更早的系统用 KVC);宿主不配置,也不按版本关闭。支持级别见文首表格。
- 角色卡、JS-Slash-Runner、同源 iframe 使用标准浏览器 Fullscreen API,不增加 JS-native bridge。退出全屏后视图重挂载,由 `KeyboardScrollView` 恢复约束。
### 4.1 构建版本契约
- 最低部署版本是 `15.0`,规范值写在 `tauri.conf.json` 的 `bundle.iOS.minimumSystemVersion`。
- `gen/apple/project.yml`、`Podfile` 与已提交 `.xcodeproj` 必须保持同值;Xcode pre-build script 会将真实 `IPHONEOS_DEPLOYMENT_TARGET` 与 Tauri 配置比较,不一致时直接失败。
- 当前 `project.yml` 未覆盖已提交 Apple host 的全部自定义 Info.plist、scheme 与签名状态。不要直接运行 XcodeGen 或 `tauri ios init` 覆盖工程;修改后只审查必要 diff,否则可能删除高刷、后台模式与文件类型声明。
- iOS 签名启用 `Increased Memory Limit`,发布 profile 需包含对应能力。
## 5. iOS 分发 Policy(当前状态)
iOS 外测/内测分发裁剪与能力分级已落地为 iOS-only 的 `ios_policy` 运行时系统(可被导入 `tauritavern-settings.json` 覆盖 profile/能力边界,且 iOS 上 fail-fast、桌面端忽略)。
- 当前实现快照:`docs/CurrentState/iOSPolicy.md`
## 6. App Icon 外观变体(iOS 18+ / macOS 26+)
### 6.1 现象
iOS 深色图标模式下,App 放入文件夹后可能出现“文件夹外仍是普通图标,打开文件夹后变成深色图标”的不一致表现。
### 6.2 根因
iOS 18+ 支持 Home Screen 图标的 `Any` / `Dark` / `Tinted` 外观。若 AppIcon 只提供传统多尺寸 `Any` 图标,系统会自动生成深色/着色效果;文件夹缩略图和展开文件夹可能走不同缓存或渲染路径,从而出现外观不一致。
### 6.3 当前方案
`AppIcon.appiconset` 改为 Xcode single-size 1024px 源图,并显式提供:
- `AppIcon-Light.png`:基础 `Any` 图标,不透明背景。
- `AppIcon-Dark.png`:深色图标,透明背景,交给系统深色底承载。
- `AppIcon-Tinted.png`:着色图标,透明背景,灰度前景。
维护入口:
- 生成脚本:`scripts/generate-ios-app-icon-variants.swift`
- 构建期校验/展平:`scripts/ios-opaque-app-icons.swift`
- 资产目录:`src-tauri/crates/tauritavern/gen/apple/Assets.xcassets/AppIcon.appiconset`
重新生成:
```sh
xcrun --sdk macosx swift scripts/generate-ios-app-icon-variants.swift \
src-tauri/crates/tauritavern/icons/icon.png \
src-tauri/crates/tauritavern/gen/apple/Assets.xcassets/AppIcon.appiconset
```
回归验证:
```sh
xcrun actool --compile /tmp/tt-appicon \
--platform iphonesimulator \
--minimum-deployment-target 15.0 \
--app-icon AppIcon \
--output-partial-info-plist /tmp/tt-appicon/partial.plist \
src-tauri/crates/tauritavern/gen/apple/Assets.xcassets
xcrun assetutil --info /tmp/tt-appicon/Assets.car
```
输出应包含 `UIAppearanceDark` 与 `ISAppearanceTintable`。若未来重新运行 `tauri icon`,必须重新生成并保留这三个 appearance 变体。
### 6.4 macOS 26+
macOS 26 会把不规则形状的 `.icns` 缩小放进灰色底板。`src-tauri/crates/tauritavern/icons/AppIcon.icon` 是 Icon Composer 文档:底色与 iOS `Any` 相同,logo 为单一图层,深色、着色与透明外观由系统生成。Tauri 打包时用 `actool`(Xcode 26+)把它编译为 `Assets.car` 并写入 `CFBundleIconName`;`icon.icns` 仍作为 `CFBundleIconFile` 供 macOS 15 及更早版本使用。
Tauri npm CLI 当前会让 `actool` 继承已关闭的 stdin,导致图标编译失败([上游问题 #15315](https://github.com/tauri-apps/tauri/issues/15315))。统一入口 `scripts/tauri-app.mjs` 在 macOS 上把 `scripts/macos` 加到子进程 PATH 前端,`actool` 入口用 `xcrun actool "$@" </dev/null` 显式提供空 stdin。升级到包含 [#15991](https://github.com/tauri-apps/tauri/pull/15991) 修复的 CLI 后,删除这个入口和 PATH 设置。
更换 logo 时,用 Icon Composer 打开该文档替换 `Assets/logo.png`(1024px 透明 PNG)。预览各外观:
```sh
"$(xcode-select -p)/../Applications/Icon Composer.app/Contents/Executables/ictool" \
src-tauri/crates/tauritavern/icons/AppIcon.icon \
--export-image --output-file /tmp/tt-macos-icon.png \
--platform macOS --rendition Dark --width 512 --height 512 --scale 1
```
## 7. AI 生成后台执行
iOS Chat Completion 的后台租约由 Rust `ChatCompletionService` 持有,不依赖 WKWebView 的 Promise、Channel 或定时器继续运行:
- iOS 26+ 的用户可见生成使用 `BGContinuedProcessingTask`,任务由用户操作立即提交,并在系统 Live Activity 中展示;
- 流式生成以累计收到的 provider 响应字节作为单调递增的真实进度。因为 LLM 输出总量事先未知,`NSProgress` 保持 indeterminate,成功结束时再收敛到 100%;
- iOS 16–25,以及不会展示系统 UI 的 quiet 内部生成,继续使用 `beginBackgroundTask`。`BGContinuedProcessingTask` 不适合无明确用户意图却会显示 Live Activity 的后台工作。
生命周期契约:
- 普通与流式 Chat Completion 在网络请求前取得后台租约;
- 成功、失败或用户取消都会由 Rust 立即完成对应的系统任务;
- 若系统先触发 expiration handler,原生适配器只结束后台保护并记录警告,不把“后台保护结束”伪装成“生成失败”;
- 系统随后可以挂起应用;若进程仍存活,生成在恢复执行后继续等待真实 provider 结果、网络错误或用户取消;
- 已产生的流事件仍由 Rust 进程内会话保留,WebView 恢复后通过 `after_seq` 继续读取;
- 无法取得后台租约只记录明确警告,不阻塞仍在前台可正常完成的生成。
当前不使用 `BGAppRefreshTask` / `BGProcessingTask`,因为它们由系统择机调度,不能表达用户点击后立即开始的聊天生成。`BGContinuedProcessingTask` 只拥有系统后台执行权和展示状态;真正的网络请求、取消和流会话生命周期仍由既有 Rust 服务负责,不引入第二套生成调度状态机。
## 8. LAN Sync Bonjour 发现
LAN 发现通过系统 Bonjour 浏览和注册 `_tauritavern._tcp`。Info.plist 声明 `NSLocalNetworkUsageDescription` 与 `NSBonjourServices`,由用户授予本地网络访问权限;此路径不需要 Multicast Networking entitlement。macOS 使用相同的系统后端与用途声明。
权限行为以真机签名包验证,后台发现受 iOS 生命周期限制。配置入口见 [Apple host 工程](../src-tauri/crates/tauritavern/gen/apple/project.yml),功能边界见[同步总览](CurrentState/Sync.md)。
## 9. Tauri Pilot
`pnpm run ios:dev:pilot` 在模拟器中启动带 Pilot 的 debug 包。模拟器共享 Mac 的文件系统,插件的 socket 位于 `/tmp/tauri-pilot-com.tauritavern.client.sock`,CLI 直接发现,无需转发。桌面开发版与模拟器使用同一路径,先启动的占用,其余实例不启动 Pilot 服务。真机沙箱对 Mac 隐藏 socket,暂不支持;iOS 上没有 `press`,文字输入用 `fill` 或 `type`。