fix (iOS): fixed an issue in WKWebView on iOS where automatic safe-area insets caused a dead zone at the bottom.

This commit is contained in:
Darkatse 2026-03-16 22:17:59 -04:00
parent 66ace9d643
commit 6e19134edb
19 changed files with 215 additions and 204 deletions

View file

@ -1,6 +1,6 @@
# TauriTavern 移动端开发说明
# TauriTavern Android 端开发说明
本文档记录当前移动端(Android / iOS)开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并为后续替换官方修复留出清晰迁移路径。
本文档记录当前移 Android 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并为后续替换官方修复留出清晰迁移路径。
## 1. Android WebView 安全区注入时机竞态
@ -33,14 +33,17 @@ https://github.com/tauri-apps/tauri/issues/14240
- 保留 edge-to-edge 与透明系统栏配置(沉浸基础);
- 监听系统栏与 IME inset;
- 将 inset 写入前端 CSS 变量:
- `--tt-safe-area-top/right/left/bottom`(系统安全区)
- Android native 注入的 CSS 变量(provider 层):
- `--tt-inset-top/right/left/bottom`(布局应避开的有效 inset:system bars + cutout 等,沉浸模式下为 0)
- `--tt-ime-bottom`(输入法可见时的底部 inset)
- `--tt-base-viewport-height`(无 IME 时的基准 viewport 高度)
- 前端布局消费的 CSS 变量(contract 层):
- `--tt-inset-top/right/left/bottom`(有效避让 inset;iOS 由 `env()` 提供,Android 由 native 注入覆盖)
- `--tt-viewport-bottom-inset`(前端通过 `max()` 合成有效底部 inset)
Android 语义说明:
Android 语义说明(以 contract 层为准):
- `--tt-safe-area-*` 表示**当前布局应避开的有效安全区**;
- `--tt-inset-*` 表示**当前布局应避开的有效 inset**;
- 非沉浸模式下,它反映 system bars + `displayCutout`(刘海/打孔)的可见/稳定 inset;
- 沉浸模式下,它会回落为 `0`,允许应用顶部 UI 与第三方 fixed 浮层以 full-bleed 方式沉入状态栏区域。
@ -54,7 +57,7 @@ Android 语义说明:
前端消费变量在:
- `src/style.css`(变量定义与 fallback)
- `src/css/mobile-styles.css`(顶部栏与容器定位使用变量)
- `src/css/mobile-styles.css`(顶部栏与容器定位使用 contract 变量)
### 1.4 维护原则
@ -165,7 +168,7 @@ https://v2.tauri.app/develop/resources/#android
### 4.4 前端接入点
- `src/scripts/templates.js`:模板读取在 Tauri 环境下走 `invoke('read_frontend_template')`
- `src/css/mobile-styles.css` + `src/style.css`:通过 `--tt-safe-area-*` 消费原生注入的安全区变量
- `src/css/mobile-styles.css` + `src/style.css`:通过 `--tt-inset-*` 消费布局契约
---
@ -244,8 +247,8 @@ https://v2.tauri.app/develop/resources/#android
- 在 Tauri mobile 安装 overlay safe-area 兼容控制器:
- 实现:`src/tauri/main/compat/mobile/mobile-overlay-compat-controller.js`
- 入口:`src/tauri/main/bootstrap.js`(仅 Android/iOS UA)
- 策略:只观察 `document.body` 直接子节点新增/移除;对疑似第三方浮层(`position: fixed` 且顶边贴近 0)按元素级别设置 `top: max(var(--tt-safe-area-top), <原top>) !important`;并在 `html.style`/`visualViewport` 变化时重新校验。
- 依赖:`--tt-safe-area-top` 表示当前布局策略;因此非沉浸模式下会提供顶部避让,沉浸模式下会自然退化为 no-op。
- 策略:只观察 `document.body` 直接子节点新增/移除;对疑似第三方浮层(`position: fixed` 且顶边贴近 0)按元素级别设置 `top: max(var(--tt-inset-top), <原top>) !important`;并在 `html.style`/`visualViewport` 变化时重新校验。
- 依赖:`--tt-inset-top` 表示当前布局策略;因此非沉浸模式下会提供顶部避让,沉浸模式下会自然退化为 no-op。
设计约束:

View file

@ -11,13 +11,15 @@
结论(当前实现的核心要点):
1. **Safe‑area 是“宿主提供的布局契约”**:Android 由 native 监听 `WindowInsets` 并注入当前布局应避开的 `--tt-safe-area-*`;iOS 以 CSS `env(safe-area-inset-*)` 为主。
2. **沉浸模式是 full-bleed 策略开关**:Android 沉浸(system bars 隐藏)时,`--tt-safe-area-*` 回落为 `0`,因此第一方顶部 UI 与第三方 fixed 浮层都允许沉入状态栏/刘海区域。
1. **Insets 是“宿主提供的布局契约”**:前端布局只消费 `--tt-inset-*`;Android 由 native 监听 `WindowInsets` 并直接注入当前布局应避开的 inset(`--tt-inset-*`),iOS 以 CSS `env(safe-area-inset-*)` 提供 `--tt-inset-*`。
2. **沉浸模式是 full-bleed 策略开关**:Android 沉浸(system bars 隐藏)时,`--tt-inset-*` 回落为 `0`,因此第一方顶部 UI 与第三方 fixed 浮层都允许沉入状态栏/刘海区域。
3. **第三方浮层只做元素级最小修正**:不重写 `<style>` 文本,不做全局 subtree observer;仅在出现“顶边贴近 0 的 fixed 浮层”时 patch `top`。
4. **iOS 禁用 WKWebView 的自动 content inset 调整**:将 `scrollView.contentInsetAdjustmentBehavior = .never` 并清空 `contentInset/scrollIndicatorInsets`,确保 `window.innerHeight` 真正覆盖到全屏;safe-area 只通过 `env(safe-area-inset-*)` 交给前端消费。
本目录记录“现状快照”,更完整的问题推导与历史路径见:
- `docs/MobileDevelopment.md`(6.3)
- `docs/AndroidDevelopment.md`
- `docs/iOSDevelopment.md`
- `docs/MobileDynamicStyleSafeAreaPatch.md`(历史链路)
## 2. 端到端链路(Android)
@ -30,7 +32,7 @@
- `WindowCompat.setDecorFitsSystemWindows(window, false)`:启用 edge‑to‑edge。
- 状态栏/导航栏透明;允许内容延伸到系统栏区域。
- `layoutInDisplayCutoutMode = SHORT_EDGES`:允许在刘海区域布局;是否避让由 `--tt-safe-area-*` 的当前策略决定。
- `layoutInDisplayCutoutMode = SHORT_EDGES`:允许在刘海区域布局;是否避让由 `--tt-inset-*` 的当前策略决定。
- system bars behavior 使用 `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`。
沉浸模式:
@ -47,14 +49,14 @@
CSS 变量(对前端的稳定契约):
- `--tt-safe-area-top/right/left/bottom`:安全区 inset(px)。
- `--tt-ime-bottom`:输入法可见时的底部 inset(px),注入在 `#sheld`(优先)或 `html`。
- `--tt-inset-top/right/left/bottom`:布局消费的有效避让 inset(px)。
- `--tt-ime-bottom`:输入法可见时的底部 inset(px),注入在 `#sheld`。
- `--tt-base-viewport-height`:记录“无 IME 时”的基准 viewport 高度(用于稳定高度计算)。
关键语义(沉浸模式 + 刘海):
- Android 非沉浸模式下,`--tt-safe-area-*` 反映当前布局应避开的可见/稳定 safe area;
- Android 沉浸模式下,`--tt-safe-area-*` 回落为 `0`,应用以 full-bleed 方式覆盖到状态栏/刘海区域。
- Android 非沉浸模式下,`--tt-inset-*` 反映当前布局应避开的可见/稳定 safe area;
- Android 沉浸模式下,`--tt-inset-*` 回落为 `0`,应用以 full-bleed 方式覆盖到状态栏/刘海区域。
## 3. 前端消费(CSS / JS)
@ -62,8 +64,8 @@ CSS 变量(对前端的稳定契约):
`src/style.css` 提供默认值(iOS/浏览器主要依赖):
- `--tt-safe-area-* = env(safe-area-inset-*, 0px)`
- `--tt-viewport-bottom-inset = max(var(--tt-safe-area-bottom), var(--tt-ime-bottom))`
- `--tt-inset-* = env(safe-area-inset-*, 0px)`(iOS)
- `--tt-viewport-bottom-inset = max(var(--tt-inset-bottom), var(--tt-ime-bottom))`
补充兜底:
@ -77,8 +79,8 @@ Android 说明:
`src/css/mobile-styles.css` 消费上述变量,主要约束点:
- 顶部容器(如 `#top-settings-holder/#top-bar`)使用 `top: max(var(--tt-safe-area-top), 0px)` 并加入左右 padding。
- 主容器 `#sheld` 以 `safe-area-top + topBarBlockSize` 定位,并用 `--tt-base-viewport-height`/`--doc-height` 计算高度;底部通过 `--tt-safe-area-bottom` 与 `--tt-ime-bottom` 合成有效 bottom inset。
- 顶部容器(如 `#top-settings-holder/#top-bar`)使用 `top: max(var(--tt-inset-top), 0px)` 并加入左右 padding。
- 主容器 `#sheld` 以 `inset-top + topBarBlockSize` 定位,并用 `--tt-base-viewport-height`/`--doc-height` 计算高度;底部通过 `#form_sheld { padding-bottom: var(--tt-inset-bottom) }` 消化 safe-area,同时用 `--tt-ime-bottom` 推导键盘偏移。
这些规则的目标是:在非沉浸模式下避开顶部/底部安全区与键盘,在沉浸模式下保持 full-bleed。
@ -91,11 +93,11 @@ Android 说明:
- **Admission**:仅观察 `document.body` 的直系子节点新增/移除(`subtree: false`)。
- **判定**:对 `position: fixed` 且计算后的 `top` 贴近 0(阈值范围内)的元素进行处理。
- **补丁**:对命中元素设置 `top: max(var(--tt-safe-area-top), <原top>) !important`。
- **补丁**:对命中元素设置 `top: max(var(--tt-inset-top), <原top>) !important`。
- **排除**:明确跳过 `body/#sheld/#chat` 等核心容器(避免影响主界面)。
- **Revalidate**:监听 `html.style` 变化(native 注入会触发)+ `visualViewport`/`resize`/`orientationchange` 以重新校验 active set。
该控制器的边界是:只对“第三方顶层浮层贴顶”做最小修正,不承担全局样式重写职责;在沉浸模式下由于 `--tt-safe-area-top = 0`,该补丁会自然退化为不额外避让顶部安全区。
该控制器的边界是:只对“第三方顶层浮层贴顶”做最小修正,不承担全局样式重写职责;在沉浸模式下由于 `--tt-inset-top = 0`,该补丁会自然退化为不额外避让顶部安全区。
### 3.4 旧 WebView JS 能力补齐(移动端)
@ -133,10 +135,10 @@ native 侧实现:`src-tauri/gen/android/app/src/main/java/com/tauritavern/clie
已支持:
- Android edge‑to‑edge + safe‑area 变量注入(包含 IME)。
- Android 沉浸模式下以 full-bleed 策略运行,顶部 safe-area 不再额外避让刘海/状态栏。
- iOS `viewport-fit=cover` + `env(safe-area-inset-*)` 消费。
- 第三方脚本 fixed 浮层的 safe‑area top 元素级修正(移动端)。
- Android edge‑to‑edge + inset 契约变量(包含 IME)。
- Android 沉浸模式下以 full-bleed 策略运行,顶部 inset 不再额外避让刘海/状态栏。
- iOS `viewport-fit=cover` + `env(safe-area-inset-*)` 提供 `--tt-inset-*`。
- 第三方脚本 fixed 浮层的 inset top 元素级修正(移动端)。
- 聊天导航类场景不再自动聚焦 `#send_textarea`,移动端键盘只在真正进入输入/编辑意图时弹出。
明确不支持 / 不承诺:
@ -155,7 +157,7 @@ native 侧实现:`src-tauri/gen/android/app/src/main/java/com/tauritavern/clie
快速调试点:
- `getComputedStyle(document.documentElement).getPropertyValue('--tt-safe-area-top')`
- `getComputedStyle(document.documentElement).getPropertyValue('--tt-inset-top')`
- 沉浸模式期望接近 `0px`
- 非沉浸模式期望反映当前顶部 safe area
- `window.__TAURITAVERN_MOBILE_OVERLAY_COMPAT__` 是否已安装

View file

@ -232,9 +232,9 @@ src/
- 触发条件:仅处理第三方浮层节点(`position: fixed` 且顶边贴近 0)。
- 处理策略:
- 观察 `document.body` 直接子节点新增/移除;
- 对命中元素设置 `top: max(var(--tt-safe-area-top), <原top>) !important`;
- 对命中元素设置 `top: max(var(--tt-inset-top), <原top>) !important`;
- 明确排除 `body/#sheld/#chat` 等应用核心容器,避免影响主界面布局。
- Android 变量语义:`--tt-safe-area-top` 表示当前布局应避开的有效 inset;非沉浸模式下反映顶部 safe area,沉浸模式下回落为 `0`,因此 overlay patch 不再额外避开顶部状态栏/刘海区域。
- Android 变量语义:`--tt-inset-top` 表示当前布局应避开的有效 inset;非沉浸模式下反映顶部 safe area,沉浸模式下回落为 `0`,因此 overlay patch 不再额外避开顶部状态栏/刘海区域。
该策略用于修复 JS-Slash-Runner 等脚本在运行时注入固定定位弹窗样式时,关闭按钮落入状态栏导致不可点击的问题。

View file

@ -10,9 +10,11 @@
4. [前端宿主契约](./FrontendHostContract.md) - Host Kernel 对上游/插件/脚本可观察行为的契约清单(重构必读)
5. [后端结构](./BackendStructure.md) - 后端架构和模块说明
6. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑
7. [移动端开发说明](./MobileDevelopment.md) - Android/iOS 关键坑位、资源访问与路径解析方案
8. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束
9. [性能重构计划:Prompt History Backfill](./PerformanceRefactoringPlan.md) - Windowed Payload 下补齐生成上下文的方案(方案 C + 页缓存)
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 解耦,修复底部死区并收敛契约
## 项目概述

51
docs/iOSDevelopment.md Normal file
View file

@ -0,0 +1,51 @@
# TauriTavern iOS 端开发说明
本文档记录当前 iOS 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并确保移动端样式契约(`--tt-inset-*`)在 iOS 上可预测、可维护。
## 1. WKWebView safe-area 自动 inset 导致底部死区
### 1.1 现象
- 页面底部出现一块灰色、不可交互的区域。
- 前端根节点(如 `#sheld`)看似已撑满 `window.innerHeight`,但依然无法覆盖到屏幕底边。
### 1.2 关键定位信号
当出现如下特征时,优先判断为 **iOS native 侧对 WebView 做了 safe-area 自动 inset 调整**,而不是纯前端 CSS 高度问题:
- `screen.height - window.innerHeight` 显著大于 0(例如 `96px`)
- 同时 `env(safe-area-inset-bottom)`(或 `--tt-inset-bottom`)仍为非 0(例如 `34px`)
这通常意味着:**Web 内容的 viewport 被系统按 safe-area 扣掉了(顶部 + 底部)**,因此 DOM 只能布局在“安全区内的可视内容区域”,无法触达屏幕真实底边。
### 1.3 根因
WKWebView 内部是 `UIScrollView` 承载 Web 内容;在默认行为下,iOS 可能对该 scroll view 启用自动的内容 inset 调整(safe-area / scroll indicator insets),导致:
- Web 内容 viewport 变小(`window.innerHeight` 被扣减)
- 产生“看得见但不可交互”的底部空白区域(它不是 DOM 的一部分)
这会与当前的移动端布局契约冲突:iOS 侧 safe-area 应由前端通过 `env(safe-area-inset-*)` → `--tt-inset-*` 统一消费,而不是由 native 再额外“帮你扣一遍”。
### 1.4 已落地方案(fail-fast)
在 iOS 端创建主窗口后,对 WKWebView 的 `scrollView` 做一次性配置:
- `scrollView.contentInsetAdjustmentBehavior = .never`
- 清空 `contentInset` 与 `scrollIndicatorInsets`
- 关闭 `automaticallyAdjustsScrollIndicatorInsets`
该策略的目标是:让 `window.innerHeight` 覆盖到 full-bleed viewport;safe-area 的避让完全交给 CSS contract(`--tt-inset-*`)控制。
实现位置:
- iOS 配置入口:`src-tauri/src/infrastructure/ios_webview.rs`
- 调用时机:`src-tauri/src/lib.rs`(主窗口 build 后立刻调用)
### 1.5 验收建议
修复后应满足:
- `screen.height - window.innerHeight` 接近 0(允许 1px 内的 rounding)
- `--tt-inset-bottom` 仍保持合理的 safe-area 值(如 `34px`),且输入框/按钮不被 home indicator 遮挡

2
src-tauri/Cargo.lock generated
View file

@ -5026,6 +5026,8 @@ dependencies = [
"local-ip-address",
"miktik",
"mime_guess",
"objc2",
"objc2-ui-kit",
"percent-encoding",
"png",
"qrcode",

View file

@ -58,6 +58,10 @@ qrcode = { version = "0.14", default-features = false, features = ["svg"] }
rustls = { version = "0.23", default-features = false, features = ["std"] }
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"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]

View file

@ -225,7 +225,11 @@ class AndroidInsetsBridge(
companion object {
private val PAGE_READY_SCRIPT =
"""
(() => document.readyState !== 'loading' && location.href !== 'about:blank')();
(() =>
document.readyState !== 'loading' &&
location.href !== 'about:blank' &&
Boolean(document.getElementById('sheld'))
)();
""".trimIndent()
}
}

View file

@ -24,17 +24,19 @@ class WebViewInsetsStyleApplier(private val resources: Resources) {
}
private fun buildApplyScript(snapshot: InsetsSnapshot): String {
val safeTop = toCssPxNumber(snapshot.systemBars.top)
val safeRight = toCssPxNumber(snapshot.systemBars.right)
val safeLeft = toCssPxNumber(snapshot.systemBars.left)
val safeBottom = toCssPxNumber(snapshot.systemBars.bottom)
val insetTop = toCssPxNumber(snapshot.systemBars.top)
val insetRight = toCssPxNumber(snapshot.systemBars.right)
val insetLeft = toCssPxNumber(snapshot.systemBars.left)
val insetBottom = toCssPxNumber(snapshot.systemBars.bottom)
val imeBottom = toCssPxNumber(snapshot.imeBottom)
return """
(() => {
const bridge = window.__TAURITAVERN_INSETS__;
if (!bridge || typeof bridge.apply !== 'function') return;
bridge.apply($safeTop, $safeRight, $safeLeft, $safeBottom, $imeBottom);
if (!bridge || typeof bridge.apply !== 'function') {
throw new Error('[TauriTavern] Android insets bridge unavailable.');
}
bridge.apply($insetTop, $insetRight, $insetLeft, $insetBottom, $imeBottom);
})();
""".trimIndent()
}
@ -54,29 +56,27 @@ class WebViewInsetsStyleApplier(private val resources: Resources) {
const state = {
baseViewportHeight: 0,
baseViewportHeightCss: '',
safeAreaTop: '',
safeAreaRight: '',
safeAreaLeft: '',
safeAreaBottom: '',
insetTop: '',
insetRight: '',
insetLeft: '',
insetBottom: '',
imeBottom: '',
imeTargetId: '',
imeTargetRef: null,
};
const setVarIfChanged = (target, cssName, stateKey, nextValue) => {
if (!target || state[stateKey] === nextValue) {
if (state[stateKey] === nextValue) {
return;
}
state[stateKey] = nextValue;
target.style.setProperty(cssName, nextValue);
};
const resolveImeTarget = (root) => document.getElementById('sheld') || root;
window.__TAURITAVERN_INSETS__ = {
apply(safeTop, safeRight, safeLeft, safeBottom, imeBottom) {
apply(insetTop, insetRight, insetLeft, insetBottom, imeBottom) {
const root = document.documentElement;
if (!root) return;
if (!root) {
throw new Error('[TauriTavern] documentElement unavailable while applying insets.');
}
const viewport = window.visualViewport;
const viewportHeight =
@ -104,34 +104,17 @@ class WebViewInsetsStyleApplier(private val resources: Resources) {
: 0;
const effectiveImeBottom = Math.max(0, imeBottom - viewportShrink);
setVarIfChanged(root, '--tt-safe-area-top', 'safeAreaTop', safeTop.toFixed(2) + 'px');
setVarIfChanged(root, '--tt-safe-area-right', 'safeAreaRight', safeRight.toFixed(2) + 'px');
setVarIfChanged(root, '--tt-safe-area-left', 'safeAreaLeft', safeLeft.toFixed(2) + 'px');
setVarIfChanged(
root,
'--tt-safe-area-bottom',
'safeAreaBottom',
safeBottom.toFixed(2) + 'px',
);
setVarIfChanged(root, '--tt-inset-top', 'insetTop', insetTop.toFixed(2) + 'px');
setVarIfChanged(root, '--tt-inset-right', 'insetRight', insetRight.toFixed(2) + 'px');
setVarIfChanged(root, '--tt-inset-left', 'insetLeft', insetLeft.toFixed(2) + 'px');
setVarIfChanged(root, '--tt-inset-bottom', 'insetBottom', insetBottom.toFixed(2) + 'px');
const imeBottomCss = effectiveImeBottom.toFixed(2) + 'px';
const imeTarget = resolveImeTarget(root);
const imeTargetId = imeTarget === root ? 'root' : 'sheld';
if (
state.imeTargetRef &&
state.imeTargetRef !== imeTarget &&
state.imeTargetRef.isConnected
) {
state.imeTargetRef.style.removeProperty('--tt-ime-bottom');
}
if (state.imeBottom !== imeBottomCss || state.imeTargetId !== imeTargetId) {
state.imeBottom = imeBottomCss;
state.imeTargetId = imeTargetId;
state.imeTargetRef = imeTarget;
imeTarget.style.setProperty('--tt-ime-bottom', imeBottomCss);
const imeTarget = document.getElementById('sheld');
if (!imeTarget) {
throw new Error('[TauriTavern] #sheld unavailable while applying IME insets.');
}
setVarIfChanged(imeTarget, '--tt-ime-bottom', 'imeBottom', imeBottomCss);
},
};
})();

View file

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.5.1</string>
<key>CFBundleVersion</key>
<string>1.0.0</string>
<string>1.5.1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>

View file

@ -0,0 +1,27 @@
use tauri::WebviewWindow;
pub fn disable_wkwebview_content_inset_adjustment(window: &WebviewWindow) -> tauri::Result<()> {
window.with_webview(|webview| unsafe {
use objc2::rc::Retained;
use objc2::runtime::AnyObject;
use objc2_ui_kit::{
UIScrollView, UIScrollViewContentInsetAdjustmentBehavior, UIEdgeInsetsZero,
};
let wkwebview_ptr = webview.inner();
assert!(
!wkwebview_ptr.is_null(),
"PlatformWebview.inner() returned a null WKWebView pointer"
);
let wkwebview = &*wkwebview_ptr.cast::<AnyObject>();
let scroll_view: Retained<UIScrollView> = objc2::msg_send![wkwebview, scrollView];
scroll_view.setContentInsetAdjustmentBehavior(
UIScrollViewContentInsetAdjustmentBehavior::Never,
);
scroll_view.setContentInset(UIEdgeInsetsZero);
scroll_view.setScrollIndicatorInsets(UIEdgeInsetsZero);
scroll_view.setAutomaticallyAdjustsScrollIndicatorInsets(false);
})
}

View file

@ -2,6 +2,8 @@
pub mod apis;
pub mod assets;
pub mod http_client;
#[cfg(target_os = "ios")]
pub mod ios_webview;
pub mod lan_sync;
pub mod logging;
pub mod paths;

View file

@ -1,4 +1,5 @@
use std::error::Error;
#[cfg(not(target_os = "ios"))]
use std::io;
#[cfg(target_os = "android")]
use std::path::Path;
@ -6,7 +7,9 @@ use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
const RUNTIME_MODE_ENV: &str = "TAURITAVERN_RUNTIME_MODE";
#[cfg(not(any(target_os = "android", target_os = "ios")))]
const PORTABLE_MARKER_FILE: &str = "portable.flag";
const DATA_ARCHIVE_ROOT_DIR: &str = ".data-archive";
const DATA_ARCHIVE_IMPORTS_DIR: &str = "imports";
@ -15,6 +18,7 @@ const DATA_ARCHIVE_EXPORTS_DIR: &str = "exports";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeMode {
Standard,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Portable,
}

View file

@ -87,7 +87,7 @@ fn create_main_window(
let global_extensions_dir = third_party_dirs.global_dir;
let user_dirs = user_dirs;
tauri::webview::WebviewWindowBuilder::from_config(app.handle(), window_config)?
let _window = tauri::webview::WebviewWindowBuilder::from_config(app.handle(), window_config)?
.on_web_resource_request(move |request, response| {
handle_third_party_asset_web_request(
&local_extensions_dir,
@ -100,5 +100,8 @@ fn create_main_window(
})
.build()?;
#[cfg(target_os = "ios")]
infrastructure::ios_webview::disable_wkwebview_content_inset_adjustment(&_window)?;
Ok(())
}

View file

@ -221,11 +221,11 @@
#top-settings-holder,
#top-bar {
position: fixed;
top: max(var(--tt-safe-area-top), 0px);
top: max(var(--tt-inset-top), 0px);
width: 100vw;
width: 100dvw;
padding-right: max(var(--tt-safe-area-right), 0px);
padding-left: max(var(--tt-safe-area-left), 0px);
padding-right: max(var(--tt-inset-right), 0px);
padding-left: max(var(--tt-inset-left), 0px);
}
#bg1,
@ -249,18 +249,18 @@
max-width: 100dvw;
left: 0 !important;
resize: none !important;
top: calc(var(--topBarBlockSize) + max(var(--tt-safe-area-top), 0px));
top: calc(var(--topBarBlockSize) + max(var(--tt-inset-top), 0px));
}
#sheld {
--tt-ime-bottom: 0px;
--tt-safe-bottom-offset: max(var(--tt-safe-area-bottom), 0px);
--tt-viewport-bottom-inset-local: max(var(--tt-safe-bottom-offset), var(--tt-ime-bottom));
--tt-keyboard-offset: max(calc(var(--tt-viewport-bottom-inset-local) - var(--tt-safe-bottom-offset)), 0px);
height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset));
height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset));
max-height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset));
max-height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset));
--tt-bottom-inset: max(var(--tt-inset-bottom), 0px);
--tt-viewport-bottom-inset-local: max(var(--tt-bottom-inset), var(--tt-ime-bottom));
--tt-keyboard-offset: max(calc(var(--tt-viewport-bottom-inset-local) - var(--tt-bottom-inset)), 0px);
height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px));
height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px));
max-height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px));
max-height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px));
overflow-y: hidden;
}
@ -300,8 +300,9 @@
z-index: 31;
transform: translate3d(0, calc(-1 * var(--tt-keyboard-offset)), 0);
will-change: transform;
padding-right: max(var(--tt-safe-area-right), 0px);
padding-left: max(var(--tt-safe-area-left), 0px);
padding-right: max(var(--tt-inset-right), 0px);
padding-left: max(var(--tt-inset-left), 0px);
padding-bottom: var(--tt-bottom-inset);
}
#chat {
@ -321,24 +322,25 @@
/* Enforce mobile safe-area + IME layout even when themes override core containers. */
body #top-settings-holder,
body #top-bar {
top: max(var(--tt-safe-area-top), var(--top-distance, 0px)) !important;
padding-right: max(var(--tt-safe-area-right), 0px) !important;
padding-left: max(var(--tt-safe-area-left), 0px) !important;
top: max(var(--tt-inset-top), var(--top-distance, 0px)) !important;
padding-right: max(var(--tt-inset-right), 0px) !important;
padding-left: max(var(--tt-inset-left), 0px) !important;
}
body #sheld {
top: calc(var(--topBarBlockSize) + max(var(--tt-safe-area-top), 0px)) !important;
height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset)) !important;
height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset)) !important;
max-height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset)) !important;
max-height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px) - var(--tt-safe-bottom-offset)) !important;
top: calc(var(--topBarBlockSize) + max(var(--tt-inset-top), 0px)) !important;
height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px)) !important;
height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px)) !important;
max-height: calc(100vh - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px)) !important;
max-height: calc(var(--tt-base-viewport-height, var(--doc-height)) - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px)) !important;
}
body #form_sheld {
position: relative !important;
transform: translate3d(0, calc(-1 * var(--tt-keyboard-offset)), 0) !important;
padding-right: max(var(--tt-safe-area-right), 0px) !important;
padding-left: max(var(--tt-safe-area-left), 0px) !important;
padding-right: max(var(--tt-inset-right), 0px) !important;
padding-left: max(var(--tt-inset-left), 0px) !important;
padding-bottom: var(--tt-bottom-inset) !important;
}
body #chat {
@ -382,7 +384,7 @@
border-right: 1px solid var(--SmartThemeBorderColor);
border-bottom: 1px solid var(--SmartThemeBorderColor);
border-radius: 0 0 20px 20px;
top: calc(var(--topBarBlockSize) + max(var(--tt-safe-area-top), 0px)) !important;
top: calc(var(--topBarBlockSize) + max(var(--tt-inset-top), 0px)) !important;
left: 0 !important;
backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
@ -425,7 +427,7 @@
width: 100%;
border-radius: 0 0 20px 20px;
margin-top: 0px;
height: calc(100% - var(--topBarBlockSize) - max(var(--tt-safe-area-top), 0px));
height: calc(100% - var(--topBarBlockSize) - max(var(--tt-inset-top), 0px));
}
.drawer25pWidth {
@ -569,79 +571,3 @@
height: unset;
}
}
/*iOS specific*/
@supports (-webkit-touch-callout: none) {
body {
margin: 0 auto;
}
#top-bar {
width: 100vw;
}
#sheld {
margin: unset;
padding: unset;
width: unset;
height: unset;
min-width: unset;
max-width: unset;
min-height: unset;
max-height: unset;
width: 100vw;
width: 100dvw;
height: calc(100vh - 36px);
height: calc(100dvh - 36px);
padding-right: max(env(safe-area-inset-right), 0px);
padding-left: max(env(safe-area-inset-left), 0px);
padding-bottom: 0;
}
body.PWA #sheld {
padding-right: max(env(safe-area-inset-right), 2px);
padding-left: max(env(safe-area-inset-left), 2px);
padding-bottom: max(env(safe-area-inset-bottom), 15px);
}
#character_popup,
#world_popup,
#left-nav-panel,
#right-nav-panel,
.drawer-content {
width: unset;
height: unset;
min-width: unset;
max-width: unset;
min-height: unset;
max-height: unset;
backdrop-filter: blur(calc(var(--SmartThemeBlurStrength) * 2));
left: 0;
right: 0;
top: 0;
margin: 0 auto;
height: calc(100vh - 70px);
height: calc(100dvh - 70px);
width: calc(100dvw - 5px);
max-height: calc(100vh - 70px);
max-height: calc(100dvh - 70px);
max-width: calc(100dvw - 5px);
}
#character_popup,
#world_popup,
.drawer-content {
margin-top: 36px;
}
.scrollableInner {
overflow-y: auto;
overflow-x: hidden;
}
#horde_model {
height: unset;
}
}

View file

@ -88,9 +88,9 @@ const defaultExampleSeparator = '***';
const defaultChatStart = '***';
const defaultToastPosition = 'toast-top-center';
const MOBILE_THEME_CHAT_WIDTH_VW = 100;
const MOBILE_SAFE_AREA_TOP = 'max(var(--tt-safe-area-top), 0px)';
const MOBILE_SAFE_AREA_RIGHT = 'max(var(--tt-safe-area-right), 0px)';
const MOBILE_SAFE_AREA_LEFT = 'max(var(--tt-safe-area-left), 0px)';
const MOBILE_INSET_TOP = 'max(var(--tt-inset-top), 0px)';
const MOBILE_INSET_RIGHT = 'max(var(--tt-inset-right), 0px)';
const MOBILE_INSET_LEFT = 'max(var(--tt-inset-left), 0px)';
const avatar_styles = {
ROUND: 0,
@ -1181,9 +1181,9 @@ function enforceMobileTopBarSafeArea() {
continue;
}
element.style.setProperty('top', MOBILE_SAFE_AREA_TOP, 'important');
element.style.setProperty('padding-right', MOBILE_SAFE_AREA_RIGHT, 'important');
element.style.setProperty('padding-left', MOBILE_SAFE_AREA_LEFT, 'important');
element.style.setProperty('top', MOBILE_INSET_TOP, 'important');
element.style.setProperty('padding-right', MOBILE_INSET_RIGHT, 'important');
element.style.setProperty('padding-left', MOBILE_INSET_LEFT, 'important');
}
}

View file

@ -113,13 +113,13 @@
--topBarBlockSize: calc(var(--topBarIconSize) + var(--topBarBlockPadding));
--topBarBlockPadding: calc(var(--mainFontSize) / 3);
/* Android WebView can report zero env(safe-area-*) values. Native layer overrides these on Android. */
--tt-safe-area-top: env(safe-area-inset-top, 0px);
--tt-safe-area-right: env(safe-area-inset-right, 0px);
--tt-safe-area-left: env(safe-area-inset-left, 0px);
--tt-safe-area-bottom: env(safe-area-inset-bottom, 0px);
/* Layout contract: Android is native-injected, iOS uses env(safe-area-inset-*). */
--tt-inset-top: env(safe-area-inset-top, 0px);
--tt-inset-right: env(safe-area-inset-right, 0px);
--tt-inset-left: env(safe-area-inset-left, 0px);
--tt-inset-bottom: env(safe-area-inset-bottom, 0px);
--tt-ime-bottom: 0px;
--tt-viewport-bottom-inset: max(var(--tt-safe-area-bottom), var(--tt-ime-bottom));
--tt-viewport-bottom-inset: max(var(--tt-inset-bottom), var(--tt-ime-bottom));
/*styles for the color picker*/
--tool-cool-color-picker-btn-bg: transparent;

View file

@ -351,7 +351,6 @@ export function bootstrapTauriMain() {
.catch(() => {});
}
}
async function initializeTauriIntegration(context, interceptors, downloadBridge, perfEnabled, perfReadyPromise) {
if (perfEnabled && perfReadyPromise) {
try {
@ -360,7 +359,6 @@ async function initializeTauriIntegration(context, interceptors, downloadBridge,
// Ignore perf HUD load failures.
}
}
if (perfEnabled) {
safePerfMark('tt:tauri:init:start');
}

View file

@ -1,7 +1,7 @@
const CONTROLLER_KEY = '__TAURITAVERN_MOBILE_OVERLAY_COMPAT__';
const SAFE_AREA_TOP_VAR = '--tt-safe-area-top';
const SAFE_AREA_TOP_REFERENCE_PATTERN = /(?:--tt-safe-area-top|safe-area-inset-top)/i;
const INSET_TOP_VAR = '--tt-inset-top';
const INSET_TOP_REFERENCE_PATTERN = /(?:--tt-inset-top|safe-area-inset-top)/i;
const NON_NUMERIC_TOP_VALUE_PATTERN = /^(auto|inherit|initial|unset|revert|revert-layer)$/i;
const ZERO_TOP_VALUE_PATTERN = /^0(?:\.0+)?(?:[a-z%]+)?$/i;
@ -228,9 +228,9 @@ function parsePixelValue(rawValue) {
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeSafeAreaTopValue(rawValue) {
function normalizeInsetTopValue(rawValue) {
const value = String(rawValue || '').trim();
if (!value || SAFE_AREA_TOP_REFERENCE_PATTERN.test(value)) {
if (!value || INSET_TOP_REFERENCE_PATTERN.test(value)) {
return null;
}
@ -240,7 +240,7 @@ function normalizeSafeAreaTopValue(rawValue) {
}
const normalizedBaseValue = ZERO_TOP_VALUE_PATTERN.test(baseValue) ? '0px' : baseValue;
return `max(var(${SAFE_AREA_TOP_VAR}), ${normalizedBaseValue})`;
return `max(var(${INSET_TOP_VAR}), ${normalizedBaseValue})`;
}
function patchOverlayTop(element) {
@ -260,16 +260,16 @@ function patchOverlayTop(element) {
}
const inlineTop = String(element.style.getPropertyValue('top') || '').trim();
const safeAreaTopValue = normalizeSafeAreaTopValue(inlineTop || computedTop);
if (!safeAreaTopValue) {
const insetTopValue = normalizeInsetTopValue(inlineTop || computedTop);
if (!insetTopValue) {
return;
}
const currentTop = String(element.style.getPropertyValue('top') || '').trim();
const currentPriority = element.style.getPropertyPriority('top') || '';
if (currentTop === safeAreaTopValue && currentPriority === 'important') {
if (currentTop === insetTopValue && currentPriority === 'important') {
return;
}
element.style.setProperty('top', safeAreaTopValue, 'important');
element.style.setProperty('top', insetTopValue, 'important');
}