From 99b1939976aba998cc9731f2832678c57f7bc890 Mon Sep 17 00:00:00 2001 From: Darkatse <39111369+Darkatse@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:39:01 -0400 Subject: [PATCH] feat (iOS policy): Implement iOS policy tiering strategy --- docs/CurrentState/README.md | 4 + docs/CurrentState/iOSPolicy.md | 254 +++++++ docs/README.md | 15 +- docs/iOSDevelopment.md | 8 + fastools/src/main.rs | 113 ++- package.json | 2 + scripts/README.md | 2 + scripts/ios-policy.mjs | 152 ++++ src-tauri/build.rs | 24 + src-tauri/src/app.rs | 8 +- src-tauri/src/app/bootstrap.rs | 7 + .../src/application/dto/bootstrap_dto.rs | 2 + .../chat_completion_service/config.rs | 22 +- .../services/chat_completion_service/mod.rs | 227 +++++- src-tauri/src/domain/ios_policy.rs | 650 ++++++++++++++++++ src-tauri/src/domain/mod.rs | 1 + src-tauri/src/domain/models/settings.rs | 27 +- .../chat_completion_repository.rs | 38 + .../http_chat_completion_repository/mod.rs | 186 ++--- .../infrastructure/logging/llm_api_logs.rs | 26 +- src-tauri/src/lib.rs | 14 + .../commands/bootstrap_commands.rs | 1 + .../presentation/commands/content_commands.rs | 11 +- .../commands/extension_commands.rs | 93 ++- .../src/presentation/commands/helpers.rs | 14 + .../commands/lan_sync_commands.rs | 25 +- .../commands/settings_commands.rs | 20 +- .../commands/stable_diffusion_commands.rs | 10 +- .../presentation/commands/update_commands.rs | 10 +- src/locales/zh-cn.json | 1 + src/locales/zh-tw.json | 1 + src/script.js | 23 + .../extensions/data-migration/index.js | 17 +- .../extensions/tauritavern-version/index.js | 45 +- .../tauritavern-version/settings.html | 4 +- src/scripts/openai.js | 57 ++ src/scripts/slash-commands.js | 190 ++--- .../setting/setting-panel/settings-popup.js | 34 +- src/scripts/tauritavern/ios-policy-ui.js | 210 ++++++ src/scripts/tauritavern/ios-policy.js | 31 + src/scripts/templates/welcomePanel.html | 4 +- src/scripts/tool-calling.js | 58 +- src/tauri/main/api/chat-utils.js | 77 +++ src/tauri/main/api/chat.js | 70 +- src/tauri/main/routes/ai-routes.js | 80 +-- src/tauri/main/routes/bootstrap-routes.js | 1 + src/tauri/main/routes/content-routes.js | 11 +- .../main/routes/openai-tokenizer-routes.js | 98 +++ 48 files changed, 2474 insertions(+), 504 deletions(-) create mode 100644 docs/CurrentState/iOSPolicy.md create mode 100644 scripts/ios-policy.mjs create mode 100644 src-tauri/src/domain/ios_policy.rs create mode 100644 src/scripts/tauritavern/ios-policy-ui.js create mode 100644 src/scripts/tauritavern/ios-policy.js create mode 100644 src/tauri/main/api/chat-utils.js create mode 100644 src/tauri/main/routes/openai-tokenizer-routes.js diff --git a/docs/CurrentState/README.md b/docs/CurrentState/README.md index 6d83782a..dcc34c59 100644 --- a/docs/CurrentState/README.md +++ b/docs/CurrentState/README.md @@ -59,3 +59,7 @@ 11. `docs/CurrentState/NativeApiFormats.md` - Custom 原生 API 格式兼容现状(OpenAI Responses / Claude Messages / Gemini Interactions) - 包含端到端链路、支持/不支持边界与持续开发约束(尤其回滚 ST 与 thought-signatures) + +12. `docs/CurrentState/iOSPolicy.md` + - iOS/iPadOS-only 分发 Policy(profile + capabilities snapshot)当前实现快照 + - 包含 settings 契约、baseline 矩阵、后端裁决点与前端 UI 投影的维护约束 diff --git a/docs/CurrentState/iOSPolicy.md b/docs/CurrentState/iOSPolicy.md new file mode 100644 index 00000000..c43e6152 --- /dev/null +++ b/docs/CurrentState/iOSPolicy.md @@ -0,0 +1,254 @@ +# iOS 分发 Policy(当前实现状态) + +本文档记录 **已经落地** 的 iOS/iPadOS-only 分发 Policy 现状:通过 `profile + capabilities snapshot` 将公开 TestFlight 包的默认裁剪收敛到一个集中、可审计、可被导入覆盖的运行时策略系统。 + +核心结论: + +- **profile 可被导入覆盖**:用户导入/覆盖 `tauritavern-settings.json` 后,必须能改变 profile 与 capability 边界(允许“导入后重启生效”)。 +- **iOS-only 生效**:非 iOS/iPadOS 平台忽略 `ios_policy`(不解析、不校验、不参与裁剪),避免 iOS policy 配置错误拖垮桌面端。 +- **fail-fast**:iOS/iPadOS 上 policy schema/keys/allowlist 一旦非法,启动期直接失败;能力被禁用时,相关命令/服务明确拒绝,不做 silent noop。 +- **无编译期 HardPolicy**:构建时只注入“出厂默认 profile”,用户始终可通过导入 settings 覆盖/解锁。 + +--- + +## 1. Settings 契约(Public Contract) + +### 1.1 存放位置(source of truth) + +- 数据目录:`/default-user/` +- 文件:`/default-user/tauritavern-settings.json` +- Policy 字段:`ios_policy`(存储为 raw JSON:`serde_json::Value`) + +实现位置: + +- 读取/写入:`src-tauri/src/infrastructure/repositories/file_settings_repository.rs` +- Settings 模型:`src-tauri/src/domain/models/settings.rs` + +> 选择 raw JSON 的原因:允许桌面端加载来自 iOS 的 settings(即使未来 iOS policy schema 演进),同时让 iOS 端在运行时严格校验并 fail-fast。 + +### 1.2 v1 最小 JSON 结构(严格校验) + +```jsonc +{ + "ios_policy": { + "version": 1, + "profile": "ios_external_beta", + "overrides": { + "capabilities": { + "updates": { "manual_check": true }, + "llm": { "endpoint_overrides": true } + } + } + } +} +``` + +约束(iOS/iPadOS 上生效): + +- `version` 必填,且必须等于 `IOS_POLICY_VERSION`(当前为 1) +- `profile` 必填,允许:`full` / `ios_internal_full` / `ios_external_beta` +- `overrides.capabilities` 只允许覆盖 **已定义** 的 capability key +- policy JSON 采用 `deny_unknown_fields`:出现未知字段(含拼写错误)直接失败 +- allowlist(如下两类)支持: + - `"all"` + - `["item-a", "item-b"]` + +实现位置: + +- schema + baseline + 解析器:`src-tauri/src/domain/ios_policy.rs` + +### 1.3 iOS-only 生效语义(范围隔离) + +- iOS/iPadOS:解析并应用 `ios_policy`,严格校验(fail-fast) +- 非 iOS/iPadOS:`ios_policy` **被忽略**,不会因为其非法导致启动失败 + +实现位置: + +- `IosPolicyScope::for_current_platform()`:`src-tauri/src/domain/ios_policy.rs` +- 解析入口:`resolve_ios_policy_activation_report()`:`src-tauri/src/domain/ios_policy.rs` + +--- + +## 2. 启动链路(Activation Report 下发) + +### 2.1 解析与缓存(一次性快照) + +当前实现为“启动期一次性解析并缓存”,变更需要重启生效: + +1. `tauritavern-settings.json` 不存在时写入 `TauriTavernSettings::default()`(iOS 会注入默认 profile,见 2.2) +2. iOS 上解析 `ios_policy` → `IosPolicyActivationReport` +3. 将 `IosPolicyActivationReport` 缓存到 `AppState.ios_policy` +4. `/api/bootstrap` 返回 `ios_policy` 快照,前端启动期读取并投影 UI + +实现位置: + +- AppState 构建:`src-tauri/src/app/bootstrap.rs` +- Bootstrap DTO:`src-tauri/src/presentation/commands/bootstrap_commands.rs` +- Host 路由:`src/tauri/main/routes/bootstrap-routes.js` +- 前端启动:`src/script.js` +- 前端读取帮助函数:`src/scripts/tauritavern/ios-policy.js` + +### 2.2 “出厂默认 profile”(仅默认值,不是硬限制) + +iOS 构建可通过 `TAURITAVERN_IOS_POLICY_PROFILE` 注入默认 profile,用于首启生成默认 `tauritavern-settings.json`: + +- iOS target:允许 `full` / `ios_internal_full` / `ios_external_beta` +- 非 iOS target:强制注入为空字符串(默认不 seed `ios_policy`) + +实现位置: + +- build-time 注入:`src-tauri/build.rs` +- default seed:`src-tauri/src/domain/models/settings.rs`(`default_ios_policy_seed()`) + +> 这只是“首次生成默认 settings”的默认值:用户导入 settings 后可覆盖 profile/overrides,且不会被系统偷偷改回去。 + +### 2.3 Activation Report(用于调试/回归) + +`IosPolicyActivationReport` 字段: + +- `scope`: `ios` / `ignored` +- `profile`: 最终采用的 profile +- `capabilities`: 最终快照(唯一真相) +- `overridden_capabilities`: overrides 生效的路径列表(用于定位“哪些能力被手动改过”) + +实现位置:`src-tauri/src/domain/ios_policy.rs` + +--- + +## 3. Profiles 与 v1 Baseline Capabilities + +基线定义(profile → baseline capabilities)在:`src-tauri/src/domain/ios_policy.rs`。 + +### 3.1 Profiles + +- `full`:无限制 +- `ios_internal_full`:接近 full,但默认关闭启动后自动检查更新 +- `ios_external_beta`:公开 TestFlight 外测的 review-safe 默认边界(可被导入覆盖解锁) + +### 3.2 Baseline(概览) + +| Capability | full | ios_internal_full | ios_external_beta | +|---|---:|---:|---:| +| `extensions.third_party_management` | ✅ | ✅ | ❌ | +| `extensions.third_party_execution` | ✅ | ✅ | ❌ | +| `extensions.system_allowlist` | `"all"` | `"all"` | `["data-migration","regex","quick-reply","tauritavern-version","token-counter"]` | +| `content.external_import` | ✅ | ✅ | ❌ | +| `updates.startup_check` | ✅ | ❌ | ❌ | +| `updates.manual_check` | ✅ | ✅ | ❌ | +| `prompts.nsfw_prompt` | ✅ | ✅ | ❌ | +| `prompts.jailbreak_prompt` | ✅ | ✅ | ❌ | +| `llm.chat_completion_sources.allowlist` | `"all"` | `"all"` | `["openai","claude","makersuite"]` | +| `llm.chat_completion_features.web_search` | ✅ | ✅ | ❌ | +| `llm.chat_completion_features.request_images` | ✅ | ✅ | ❌ | +| `llm.endpoint_overrides` | ✅ | ✅ | ❌ | +| `llm.text_completions.enabled` | ✅ | ✅ | ❌ | +| `network.request_proxy` | ✅ | ✅ | ❌ | +| `scripting.prompt_injections` | ✅ | ✅ | ❌ | +| `scripting.tool_registration` | ✅ | ✅ | ❌ | +| `ai.image_generation` | ✅ | ✅ | ❌ | +| `sync.lan` | ✅ | ✅ | ❌ | + +--- + +## 4. 裁决点覆盖(当前已落地) + +本节只描述 **已经落地** 的裁决点;未覆盖的能力会明确标注为“仅 UI 投影”。 + +### 4.1 后端裁决(不可绕过) + +通用拒绝 helper: + +- `ensure_ios_policy_allows(...)`:`src-tauri/src/presentation/commands/helpers.rs` + +能力 → 落点: + +- `extensions.third_party_management` + - Rust commands:`src-tauri/src/presentation/commands/extension_commands.rs` + - `install_extension` / `update_extension` / `delete_extension` / `move_extension` / `get_extension_version` +- `extensions.third_party_execution` + `extensions.system_allowlist` + - Rust commands:`src-tauri/src/presentation/commands/extension_commands.rs:get_extensions` + - 行为:发现阶段过滤(system 仅 allowlist;local/global third-party 受 `third_party_execution` 控制) +- `content.external_import` + - Rust commands:`src-tauri/src/presentation/commands/content_commands.rs:download_external_import_url` +- `updates.manual_check` + - Rust commands:`src-tauri/src/presentation/commands/update_commands.rs:check_for_update` +- `llm.chat_completion_sources.allowlist` + - Service:`src-tauri/src/application/services/chat_completion_service/mod.rs` +- `llm.endpoint_overrides` + - Service:`src-tauri/src/application/services/chat_completion_service/mod.rs` + - 行为:禁用时禁止 `custom` source;并拒绝任何非空 override 字段(reverse_proxy/custom_url/custom_include_headers/proxy_password) +- `llm.chat_completion_features.web_search` / `llm.chat_completion_features.request_images` + - Service:`src-tauri/src/application/services/chat_completion_service/mod.rs` + - 行为:payload 中出现 enable_web_search/request_images 或 request_image_* 即拒绝 +- `network.request_proxy` + - 启动期:`src-tauri/src/lib.rs` 若 settings 中 request_proxy.enabled=true 但 capability 禁用 → 直接启动失败 + - 运行期:`src-tauri/src/presentation/commands/settings_commands.rs:update_tauritavern_settings` 禁止启用 proxy +- `ai.image_generation` + - Rust commands:`src-tauri/src/presentation/commands/stable_diffusion_commands.rs:sd_handle` +- `sync.lan` + - Rust commands:`src-tauri/src/presentation/commands/lan_sync_commands.rs:*`(所有 LAN sync 命令统一门禁) + +### 4.2 启动关键路径裁剪(避免外测默认自爆) + +- `updates.startup_check` + - 前端系统扩展 `tauritavern-version` 在 `APP_READY` 时检查 capability 并跳过启动更新检查 + - 实现:`src/scripts/extensions/tauritavern-version/index.js` +- `llm.text_completions.enabled` + - 若用户 settings 里 `main_api === "textgenerationwebui"`,iOS policy 禁用时会在加载 settings 后强制切回 chat completion(并 toast 提示) + - 实现:`src/script.js` + +### 4.3 前端 UI 投影(入口隐藏/收口) + +统一入口(启动期): + +- 将 bootstrap 的 `ios_policy` 写入 `window.__TAURITAVERN__.iosPolicy`,随后立刻执行 UI 投影 + - 实现:`src/script.js` + +主要投影点: + +- 核心 UI 投影:`src/scripts/tauritavern/ios-policy-ui.js` + - `extensions.third_party_management=false`:隐藏扩展管理入口(Install/Manage/notify updates) + - `content.external_import=false`:隐藏 external import 按钮 + onboarding 文案块 + - `prompts.nsfw_prompt=false`:隐藏 NSFW quick edit textarea 区块(仅 UI 投影) + - `prompts.jailbreak_prompt=false`:隐藏 Jailbreak quick edit textarea + “prefer character jailbreak”开关(仅 UI 投影) + - `llm.chat_completion_sources.allowlist`:过滤 `#chat_completion_source` 选项并修正当前值 + - `llm.endpoint_overrides=false`:隐藏 reverse proxy / custom endpoint 相关 UI + - `llm.chat_completion_features.web_search=false`:隐藏 web search 开关 + - `llm.chat_completion_features.request_images=false`:隐藏 request images block + - `llm.text_completions.enabled=false`:移除 Text Completion option 并隐藏 panel + - `ai.image_generation=false`:隐藏 `#bg_chat_hint`(避免 SD 相关提示露出) +- Settings 面板投影:`src/scripts/tauri/setting/setting-panel/settings-popup.js` + - `network.request_proxy=false`:隐藏 Request Proxy details(并在不支持 data root 选择时隐藏 system panel) + - `sync.lan=false`:隐藏 Sync panel(避免触发相机/局域网相关入口) +- 外部导入点击门禁:`src/script.js` + - `content.external_import=false` 时直接 toast 并 return(避免“隐藏失效/脚本触发”) +- LLM source selector 兜底:`src/scripts/openai.js` + - `llm.chat_completion_sources.allowlist` + `llm.endpoint_overrides`:当当前选择不允许时自动回落到允许项(并 toast) +- Slash Commands 注册收口: + - `scripting.prompt_injections=false`:不注册 `/inject` `/listinjects` `/flushinject(s)` 等命令 + - `src/scripts/slash-commands.js` + - `scripting.tool_registration=false`:不注册 `/tools-register` 等注册类命令 + - `src/scripts/tool-calling.js` +- `tauritavern-version` 扩展(更新能力 UI): + - `updates.manual_check=false`:移除 “Check for Updates” 按钮,并隐藏兼容性信息/Discord 链接 + - `updates.startup_check=false`:跳过启动检查更新 + - `src/scripts/extensions/tauritavern-version/index.js` + +> 当前仍存在少量按 `profile` 的 UI 差异(如外测隐藏 Discord 链接/兼容信息、data-migration 文案替换);新增功能应优先以 `capabilities.*` 为唯一判断条件,避免 profile 分支扩散。 + +--- + +## 5. 已知边界(刻意未做/仅 UI) + +- `prompts.nsfw_prompt` / `prompts.jailbreak_prompt` + - 当前为 **UI 投影级别**(隐藏 quick-edit 入口),尚未在“生成请求 payload”层做后端拒绝/校验。 + - 这意味着:若未来出现绕过 UI 的 prompt 注入链路,仍需在生成链路追加更硬的裁决点(应以 capabilities 为唯一真相)。 + +--- + +## 6. 维护约束(防腐化要点) + +1. 新增/调整能力时,先改 `src-tauri/src/domain/ios_policy.rs`(capability key + baseline + overrides),再落裁决点与 UI 投影;禁止直接在 UI 到处加 `if (profile === ...)`。 +2. iOS 上拒绝 silent fallback:策略非法/能力禁用必须“明确失败或明确拒绝”,避免后续难排查。 +3. 非 iOS 平台必须继续 **忽略** `ios_policy`,防止跨平台 settings 导入把桌面端拖死。 + diff --git a/docs/README.md b/docs/README.md index 8ea9160d..95e5b73e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,13 +12,14 @@ 6. [实施计划](./ImplementationPlan.md) - 项目实施的阶段和里程碑 7. [Android 端开发说明](./AndroidDevelopment.md) - Android WebView/Insets 注入、资源访问与路径解析方案 8. [iOS 端开发说明](./iOSDevelopment.md) - WKWebView 行为差异、safe-area/viewport-fit 与底部死区修复 -9. [iOS 通用导出桥实现计划](./IosGenericExportBridgePlan.md) - iOS 上将浏览器式导出统一接入原生 Share Sheet 的实施方案 -10. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束 -11. [扩展 API 文档](./API/README.md) - `window.__TAURITAVERN__.api.*` 的参考与适配指南(面向扩展作者) -12. [TauriTavern-Creator-Extension 实现计划](./TauriTavernCreatorExtensionPlan.md) - 面向样板扩展的整体架构、分层、阶段路线与 ROI 分析 -13. [宿主扩展 API 规范化计划](./TauriTavernHostExtensionApiPlan.md) - TauriTavern 本体应新增/整理的正式 ABI 设计与落地顺序 -14. [Workshop 回调认证重构计划](./WorkshopAuthRefactorPlan.md) - 桌面端 100% 兼容 window.open 弹窗回调,移动端优先覆盖 token/轮询类 -15. [Dev Logging 性能重构计划书](./DevLoggingPerformanceRefactorPlan.md) - 日志捕获/存储/展示链路的有界化与性能治理路线图 +9. [iOS 分发 Policy(当前状态)](./CurrentState/iOSPolicy.md) - iOS/iPadOS-only:profile 可被导入覆盖的 Policy/Capabilities 契约与裁决点快照 +10. [iOS 通用导出桥实现计划](./IosGenericExportBridgePlan.md) - iOS 上将浏览器式导出统一接入原生 Share Sheet 的实施方案 +11. [现状说明](./CurrentState/README.md) - 当前实现状态快照与持续开发约束 +12. [扩展 API 文档](./API/README.md) - `window.__TAURITAVERN__.api.*` 的参考与适配指南(面向扩展作者) +13. [TauriTavern-Creator-Extension 实现计划](./TauriTavernCreatorExtensionPlan.md) - 面向样板扩展的整体架构、分层、阶段路线与 ROI 分析 +14. [宿主扩展 API 规范化计划](./TauriTavernHostExtensionApiPlan.md) - TauriTavern 本体应新增/整理的正式 ABI 设计与落地顺序 +15. [Workshop 回调认证重构计划](./WorkshopAuthRefactorPlan.md) - 桌面端 100% 兼容 window.open 弹窗回调,移动端优先覆盖 token/轮询类 +16. [Dev Logging 性能重构计划书](./DevLoggingPerformanceRefactorPlan.md) - 日志捕获/存储/展示链路的有界化与性能治理路线图 ## 项目概述 diff --git a/docs/iOSDevelopment.md b/docs/iOSDevelopment.md index f53041b2..3d1bcbb3 100644 --- a/docs/iOSDevelopment.md +++ b/docs/iOSDevelopment.md @@ -2,6 +2,8 @@ 本文档记录当前 iOS 端开发中已经踩过的关键问题、根因分析、已落地方案,以及对应的架构改动。目标是避免重复踩坑,并确保移动端样式契约(`--tt-inset-*`)在 iOS 上可预测、可维护。 +补充:iOS/iPadOS 的 **分发 Policy(profile + capabilities snapshot)** 属于“合规裁剪/能力分级”问题域,其当前实现快照与维护约束已收敛到 `docs/CurrentState/iOSPolicy.md`,本文件仍聚焦 WKWebView 行为差异与 iOS-only 桥接。 + ## 1. WKWebView safe-area 自动 inset 导致底部死区 ### 1.1 现象 @@ -170,3 +172,9 @@ iOS 上“文件选择 / 文件导出”必须交给系统级能力完成: ### 4.4 支持边界 - 该能力依赖 iOS 16 的 app-embedded WKWebView Fullscreen API,因此项目 iOS 支持线提升到 `iOS 16+`。 + +## 5. iOS 分发 Policy(当前状态) + +iOS 外测/内测分发裁剪与能力分级已落地为 iOS-only 的 `ios_policy` 运行时系统(可被导入 `tauritavern-settings.json` 覆盖 profile/能力边界,且 iOS 上 fail-fast、桌面端忽略)。 + +- 当前实现快照:`docs/CurrentState/iOSPolicy.md` diff --git a/fastools/src/main.rs b/fastools/src/main.rs index 002f4e6d..35bda267 100644 --- a/fastools/src/main.rs +++ b/fastools/src/main.rs @@ -19,6 +19,45 @@ mod upsync; const TAOBAO_REGISTRY: &str = "https://registry.npmmirror.com"; +#[derive(Debug, Clone, Copy)] +enum IosPolicyProfileSelection { + Full, + IosInternalFull, + IosExternalBeta, +} + +impl IosPolicyProfileSelection { + fn as_str(&self) -> &'static str { + match self { + Self::Full => "full", + Self::IosInternalFull => "ios_internal_full", + Self::IosExternalBeta => "ios_external_beta", + } + } +} + +fn prompt_ios_policy_profile(action: &str) -> Result> { + let selections = &[ + "✅ full(默认:无限制)", + "🧪 ios_internal_full(内部:全功能,默认禁用启动更新检查)", + "🚦 ios_external_beta(外测:review-safe 默认裁剪,可被导入覆盖解锁)", + "🔙 返回 (Back)", + ]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt(format!("{}:选择 iOS Policy Profile", action)) + .default(0) + .items(&selections[..]) + .interact()?; + + match selection { + 0 => Ok(Some(IosPolicyProfileSelection::Full)), + 1 => Ok(Some(IosPolicyProfileSelection::IosInternalFull)), + 2 => Ok(Some(IosPolicyProfileSelection::IosExternalBeta)), + _ => Ok(None), + } +} + // 日志辅助函数 fn log_info(msg: &str) { let time = Local::now().format("%H:%M:%S").to_string(); @@ -735,12 +774,35 @@ fn run_android_dev() -> Result<()> { } fn run_ios_dev() -> Result<()> { - log_info("正在启动 iOS 开发模式..."); + let Some(profile) = prompt_ios_policy_profile("iOS Dev")? else { + return Ok(()); + }; + + log_info(&format!( + "正在启动 iOS 开发模式... (policy: {})", + profile.as_str() + )); let status = run_sequential_attempts(&[ - ("pnpm", vec!["run", "ios:dev"]), - ("corepack", vec!["pnpm", "run", "ios:dev"]), - ("npm", vec!["run", "ios:dev"]), + ( + "pnpm", + vec!["run", "ios:dev:policy", "--", "--profile", profile.as_str()], + ), + ( + "corepack", + vec![ + "pnpm", + "run", + "ios:dev:policy", + "--", + "--profile", + profile.as_str(), + ], + ), + ( + "npm", + vec!["run", "ios:dev:policy", "--", "--profile", profile.as_str()], + ), ])?; if !status.success() { @@ -870,12 +932,47 @@ fn run_android_build_split_abi() -> Result<()> { } fn run_ios_build() -> Result<()> { - log_info("正在构建 iOS 生产版本..."); + let Some(profile) = prompt_ios_policy_profile("iOS Build")? else { + return Ok(()); + }; + + log_info(&format!( + "正在构建 iOS 生产版本... (policy: {})", + profile.as_str() + )); let status = run_sequential_attempts(&[ - ("pnpm", vec!["run", "ios:build"]), - ("corepack", vec!["pnpm", "run", "ios:build"]), - ("npm", vec!["run", "ios:build"]), + ( + "pnpm", + vec![ + "run", + "ios:build:policy", + "--", + "--profile", + profile.as_str(), + ], + ), + ( + "corepack", + vec![ + "pnpm", + "run", + "ios:build:policy", + "--", + "--profile", + profile.as_str(), + ], + ), + ( + "npm", + vec![ + "run", + "ios:build:policy", + "--", + "--profile", + profile.as_str(), + ], + ), ])?; if status.success() { diff --git a/package.json b/package.json index cbbfcda0..65681d46 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "android:build": "tauri android build", "ios:dev": "tauri ios dev", "ios:build": "tauri ios build", + "ios:dev:policy": "node scripts/ios-policy.mjs dev", + "ios:build:policy": "node scripts/ios-policy.mjs build", "fastools:build": "cargo build --release --manifest-path fastools/Cargo.toml", "fastools:run": "cargo run --manifest-path fastools/Cargo.toml" }, diff --git a/scripts/README.md b/scripts/README.md index aab489c6..d7f79488 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -53,6 +53,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\export-sillytavern-migration. 用 `rg` 快速审计若干运行时依赖锚点,属于开发期排查辅助脚本。 - `tauri-ios-xcode-script.sh` 包装 `tauri ios xcode-script`,补齐 Xcode GUI 构建环境中的 PATH / Node / pnpm,并在构建后处理 iOS 图标。 +- `ios-policy.mjs` + iOS Dev/Build 包装脚本:为构建过程注入 `TAURITAVERN_IOS_POLICY_PROFILE`,并在 `ios_internal_full` / `ios_external_beta` 构建时自动使用 `--export-method app-store-connect`。 - `ios-opaque-app-icons.swift` 将 iOS App Icon PNG 展平为不透明背景,供 `tauri-ios-xcode-script.sh` 调用。 - `ci/setup-macos-signing.sh` diff --git a/scripts/ios-policy.mjs b/scripts/ios-policy.mjs new file mode 100644 index 00000000..62479626 --- /dev/null +++ b/scripts/ios-policy.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const ALLOWED_PROFILES = new Set(['full', 'ios_internal_full', 'ios_external_beta']); + +function printHelp() { + console.log(`Usage: node scripts/ios-policy.mjs [options] [-- ] + +Options: + --profile iOS policy profile (full | ios_internal_full | ios_external_beta) + --help Show this help message + +Behavior: + - Sets TAURITAVERN_IOS_POLICY_PROFILE for the build/dev process. + - For profile ios_internal_full / ios_external_beta, iOS builds add: + --export-method app-store-connect + (full does not specify export-method). +`); +} + +function resolveRepoRoot() { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(scriptDir, '..'); +} + +function parseArgs(argv) { + const options = { + mode: null, + profile: null, + extraTauriArgs: [], + }; + + if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') { + printHelp(); + process.exit(0); + } + + options.mode = argv[0]; + + let passThrough = false; + for (let index = 1; index < argv.length; index += 1) { + const value = argv[index]; + + if (passThrough) { + options.extraTauriArgs.push(value); + continue; + } + + if (value === '--') { + const next = argv[index + 1]; + if (next === '--profile' || next === '--help' || next === '-h') { + continue; + } + + passThrough = true; + options.extraTauriArgs.push('--'); + continue; + } + + if (value === '--help' || value === '-h') { + printHelp(); + process.exit(0); + } + + if (value === '--profile') { + const profile = argv[index + 1]; + if (!profile) { + throw new Error('Missing value for --profile'); + } + options.profile = profile; + index += 1; + continue; + } + + options.extraTauriArgs.push(value); + } + + return options; +} + +function shouldAddExportMethod(profile) { + return profile === 'ios_internal_full' || profile === 'ios_external_beta'; +} + +function hasExportMethodFlag(args) { + return args.some((value) => value === '--export-method' || value.startsWith('--export-method=')); +} + +function main() { + const repoRoot = resolveRepoRoot(); + process.chdir(repoRoot); + + const options = parseArgs(process.argv.slice(2)); + if (options.mode !== 'dev' && options.mode !== 'build') { + throw new Error(`Unsupported mode: ${options.mode}. Expected 'dev' or 'build'.`); + } + + const profile = (options.profile || '').trim(); + if (!ALLOWED_PROFILES.has(profile)) { + throw new Error( + `Invalid --profile value: ${profile || '(missing)'}. Expected one of: full, ios_internal_full, ios_external_beta.`, + ); + } + + if (process.platform !== 'darwin') { + throw new Error('iOS dev/build requires macOS.'); + } + + const tauriCli = path.join(repoRoot, 'node_modules', '@tauri-apps', 'cli', 'tauri.js'); + if (!existsSync(tauriCli)) { + throw new Error(`Missing Tauri CLI at ${tauriCli}. Run pnpm install first.`); + } + + const env = { + ...process.env, + TAURITAVERN_IOS_POLICY_PROFILE: profile, + }; + + const tauriArgs = ['ios', options.mode, ...options.extraTauriArgs]; + + if ( + options.mode === 'build' + && shouldAddExportMethod(profile) + && !hasExportMethodFlag(tauriArgs) + ) { + tauriArgs.splice(2, 0, '--export-method', 'app-store-connect'); + } + + const result = spawnSync(process.execPath, [tauriCli, ...tauriArgs], { + stdio: 'inherit', + env, + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0ad0cbc3..e928a66b 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -13,8 +13,10 @@ fn main() { println!("cargo:rerun-if-changed=../.git/refs"); println!("cargo:rerun-if-env-changed=GITHUB_REF_NAME"); println!("cargo:rerun-if-env-changed=GITHUB_SHA"); + println!("cargo:rerun-if-env-changed=TAURITAVERN_IOS_POLICY_PROFILE"); emit_git_build_metadata(); + emit_ios_policy_build_profile(); if let Err(error) = generate_resource_artifacts() { panic!("Failed to generate resource artifacts: {}", error); @@ -47,6 +49,28 @@ fn emit_git_build_metadata() { ); } +fn emit_ios_policy_build_profile() { + let target = std::env::var("TARGET").unwrap_or_default(); + let is_ios_target = target.contains("-apple-ios"); + + let raw_profile = std::env::var("TAURITAVERN_IOS_POLICY_PROFILE").unwrap_or_default(); + let normalized = raw_profile.trim(); + let profile = if is_ios_target { normalized } else { "" }; + + if is_ios_target && !profile.is_empty() { + match profile { + "full" | "ios_internal_full" | "ios_external_beta" => {} + value => { + panic!( + "TAURITAVERN_IOS_POLICY_PROFILE has unsupported value {value:?}. Expected one of: full, ios_internal_full, ios_external_beta." + ); + } + } + } + + println!("cargo:rustc-env=TAURITAVERN_IOS_POLICY_PROFILE={}", profile); +} + fn run_git_command(args: &[&str]) -> Option { let output = Command::new("git").args(args).output().ok()?; if !output.status.success() { diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index b3bbf419..346ef7ad 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -26,6 +26,7 @@ use crate::application::services::user_directory_service::UserDirectoryService; use crate::application::services::user_service::UserService; use crate::application::services::world_info_service::WorldInfoService; use crate::domain::errors::DomainError; +use crate::infrastructure::logging::logger; use crate::infrastructure::paths::RuntimePaths; mod bootstrap; @@ -55,6 +56,7 @@ pub struct AppState { pub lan_sync_service: Arc, pub tt_sync_service: Arc, pub update_service: Arc, + pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport, } impl AppState { @@ -101,6 +103,7 @@ impl AppState { lan_sync_service: services.lan_sync_service, tt_sync_service: services.tt_sync_service, update_service: services.update_service, + ios_policy: services.ios_policy, }) } @@ -144,7 +147,10 @@ pub fn spawn_initialization(app_handle: AppHandle, runtime_paths: RuntimePaths) } } Err(error) => { - tracing::error!("Failed to initialize application state: {}", error); + logger::error(&format!( + "Failed to initialize application state: {}", + error + )); match app_handle.emit("app-error", error.to_string()) { Ok(_) => {} diff --git a/src-tauri/src/app/bootstrap.rs b/src-tauri/src/app/bootstrap.rs index 2811b06f..745d9b23 100644 --- a/src-tauri/src/app/bootstrap.rs +++ b/src-tauri/src/app/bootstrap.rs @@ -101,6 +101,7 @@ pub(super) struct AppServices { pub lan_sync_service: Arc, pub tt_sync_service: Arc, pub update_service: Arc, + pub ios_policy: crate::domain::ios_policy::IosPolicyActivationReport, } struct AppRepositories { @@ -145,6 +146,10 @@ pub(super) async fn build_services( .settings_repository .load_tauritavern_settings() .await?; + let ios_policy = crate::domain::ios_policy::resolve_ios_policy_activation_report( + crate::domain::ios_policy::IosPolicyScope::for_current_platform(), + tauritavern_settings.ios_policy.as_ref(), + )?; let content_service = Arc::new(ContentService::new(repositories.content_repository.clone())); let extension_service = Arc::new(ExtensionService::new( @@ -168,6 +173,7 @@ pub(super) async fn build_services( repositories.secret_repository.clone(), repositories.settings_repository.clone(), repositories.prompt_cache_repository.clone(), + ios_policy.clone(), )); let tokenization_service = Arc::new(TokenizationService::new(repositories.tokenizer_repository)); @@ -239,6 +245,7 @@ pub(super) async fn build_services( lan_sync_service, tt_sync_service, update_service, + ios_policy, }) } diff --git a/src-tauri/src/application/dto/bootstrap_dto.rs b/src-tauri/src/application/dto/bootstrap_dto.rs index 4822a057..47d0649c 100644 --- a/src-tauri/src/application/dto/bootstrap_dto.rs +++ b/src-tauri/src/application/dto/bootstrap_dto.rs @@ -4,6 +4,7 @@ use crate::application::dto::character_dto::CharacterDto; use crate::application::dto::group_dto::GroupDto; use crate::application::dto::secret_dto::SecretStateDto; use crate::application::dto::settings_dto::SillyTavernSettingsResponseDto; +use crate::domain::ios_policy::IosPolicyActivationReport; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BootstrapSnapshotDto { @@ -12,4 +13,5 @@ pub struct BootstrapSnapshotDto { pub groups: Vec, pub avatars: Vec, pub secret_state: SecretStateDto, + pub ios_policy: IosPolicyActivationReport, } diff --git a/src-tauri/src/application/services/chat_completion_service/config.rs b/src-tauri/src/application/services/chat_completion_service/config.rs index d5c990e1..0c9411a8 100644 --- a/src-tauri/src/application/services/chat_completion_service/config.rs +++ b/src-tauri/src/application/services/chat_completion_service/config.rs @@ -153,8 +153,7 @@ async fn resolve_api_config( ) })?; - read_required_secret(secret_repository, secret_key, source_display_name(source)) - .await? + read_required_secret(secret_repository, secret_key, source.display_name()).await? }; Ok(ChatCompletionApiConfig { @@ -303,25 +302,6 @@ fn source_secret_key(source: ChatCompletionSource) -> Option<&'static str> { } } -fn source_display_name(source: ChatCompletionSource) -> &'static str { - match source { - ChatCompletionSource::OpenAi => "OpenAI", - ChatCompletionSource::OpenRouter => "OpenRouter", - ChatCompletionSource::Claude => "Claude", - ChatCompletionSource::Makersuite => "Google Gemini", - ChatCompletionSource::VertexAi => "Google Vertex AI", - ChatCompletionSource::DeepSeek => "DeepSeek", - ChatCompletionSource::Cohere => "Cohere", - ChatCompletionSource::Groq => "Groq", - ChatCompletionSource::Moonshot => "Moonshot AI", - ChatCompletionSource::NanoGpt => "NanoGPT", - ChatCompletionSource::Chutes => "Chutes", - ChatCompletionSource::SiliconFlow => "SiliconFlow", - ChatCompletionSource::Zai => "Z.AI (GLM)", - ChatCompletionSource::Custom => "Custom OpenAI", - } -} - fn supports_reverse_proxy(source: ChatCompletionSource) -> bool { matches!( source, diff --git a/src-tauri/src/application/services/chat_completion_service/mod.rs b/src-tauri/src/application/services/chat_completion_service/mod.rs index 0e22f568..300d84e3 100644 --- a/src-tauri/src/application/services/chat_completion_service/mod.rs +++ b/src-tauri/src/application/services/chat_completion_service/mod.rs @@ -1,13 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use tokio::sync::{RwLock, watch}; use crate::application::dto::chat_completion_dto::{ ChatCompletionGenerateRequestDto, ChatCompletionStatusRequestDto, }; use crate::application::errors::ApplicationError; +use crate::domain::ios_policy::{IosPolicyActivationReport, IosPolicyScope}; use crate::domain::models::settings::{PromptCacheTtl, TauriTavernSettings}; use crate::domain::repositories::chat_completion_repository::{ ChatCompletionCancelReceiver, ChatCompletionRepository, ChatCompletionSource, @@ -23,13 +24,14 @@ mod payload; mod prompt_caching; mod vertexai_auth; -const OPENAI_SOURCE: &str = "openai"; +const OPENAI_SOURCE: &str = ChatCompletionSource::OpenAi.key(); pub struct ChatCompletionService { chat_completion_repository: Arc, secret_repository: Arc, settings_repository: Arc, prompt_cache_repository: Arc, + ios_policy: IosPolicyActivationReport, active_streams: CancellationRegistry, active_generations: CancellationRegistry, } @@ -40,17 +42,229 @@ impl ChatCompletionService { secret_repository: Arc, settings_repository: Arc, prompt_cache_repository: Arc, + ios_policy: IosPolicyActivationReport, ) -> Self { Self { chat_completion_repository, secret_repository, settings_repository, prompt_cache_repository, + ios_policy, active_streams: CancellationRegistry::default(), active_generations: CancellationRegistry::default(), } } + fn ios_policy_is_active(&self) -> bool { + self.ios_policy.scope == IosPolicyScope::Ios + } + + fn ensure_chat_completion_source_allowed( + &self, + source: ChatCompletionSource, + ) -> Result<(), ApplicationError> { + if !self.ios_policy_is_active() { + return Ok(()); + } + + if self + .ios_policy + .capabilities + .llm + .chat_completion_sources + .allows_source(source) + { + return Ok(()); + } + + Err(ApplicationError::PermissionDenied(format!( + "iOS policy disabled chat completion source: {}", + source.key() + ))) + } + + fn ensure_endpoint_overrides_allowed_for_status( + &self, + source: ChatCompletionSource, + dto: &ChatCompletionStatusRequestDto, + ) -> Result<(), ApplicationError> { + if !self.ios_policy_is_active() { + return Ok(()); + } + + if self.ios_policy.capabilities.llm.endpoint_overrides { + return Ok(()); + } + + if source == ChatCompletionSource::Custom { + return Err(ApplicationError::PermissionDenied( + "iOS policy disabled capability: llm.endpoint_overrides".to_string(), + )); + } + + let mut overridden = Vec::new(); + if !dto.reverse_proxy.trim().is_empty() { + overridden.push("reverse_proxy"); + } + if !dto.proxy_password.trim().is_empty() { + overridden.push("proxy_password"); + } + if !dto.custom_url.trim().is_empty() { + overridden.push("custom_url"); + } + if !dto.custom_include_headers.trim().is_empty() { + overridden.push("custom_include_headers"); + } + + if overridden.is_empty() { + return Ok(()); + } + + Err(ApplicationError::PermissionDenied(format!( + "iOS policy disabled capability: llm.endpoint_overrides (used: {})", + overridden.join(", ") + ))) + } + + fn ensure_endpoint_overrides_allowed_for_payload( + &self, + source: ChatCompletionSource, + payload: &Map, + ) -> Result<(), ApplicationError> { + if !self.ios_policy_is_active() { + return Ok(()); + } + + if self.ios_policy.capabilities.llm.endpoint_overrides { + return Ok(()); + } + + if source == ChatCompletionSource::Custom { + return Err(ApplicationError::PermissionDenied( + "iOS policy disabled capability: llm.endpoint_overrides".to_string(), + )); + } + + let mut overridden = Vec::new(); + for key in [ + "reverse_proxy", + "proxy_password", + "custom_url", + "custom_include_headers", + ] { + let Some(value) = payload.get(key) else { + continue; + }; + + let value = value.as_str().ok_or_else(|| { + ApplicationError::ValidationError(format!( + "Chat completion request field must be a string: {}", + key + )) + })?; + + if !value.trim().is_empty() { + overridden.push(key); + } + } + + if overridden.is_empty() { + return Ok(()); + } + + Err(ApplicationError::PermissionDenied(format!( + "iOS policy disabled capability: llm.endpoint_overrides (used: {})", + overridden.join(", ") + ))) + } + + fn ensure_chat_completion_features_allowed( + &self, + payload: &Map, + ) -> Result<(), ApplicationError> { + if !self.ios_policy_is_active() { + return Ok(()); + } + + if let Some(value) = payload.get("enable_web_search") { + let enabled = value.as_bool().ok_or_else(|| { + ApplicationError::ValidationError( + "Chat completion request field must be a boolean: enable_web_search" + .to_string(), + ) + })?; + + if enabled + && !self + .ios_policy + .capabilities + .llm + .chat_completion_features + .web_search + { + return Err(ApplicationError::PermissionDenied( + "iOS policy disabled capability: llm.chat_completion_features.web_search" + .to_string(), + )); + } + } + + let request_images_enabled = match payload.get("request_images") { + None => false, + Some(value) => value.as_bool().ok_or_else(|| { + ApplicationError::ValidationError( + "Chat completion request field must be a boolean: request_images".to_string(), + ) + })?, + }; + + let request_image_resolution = payload.get("request_image_resolution"); + let request_image_aspect_ratio = payload.get("request_image_aspect_ratio"); + + let mut request_image_overrides = Vec::new(); + for (key, value) in [ + ("request_image_resolution", request_image_resolution), + ("request_image_aspect_ratio", request_image_aspect_ratio), + ] { + let Some(value) = value else { + continue; + }; + + let value = value.as_str().ok_or_else(|| { + ApplicationError::ValidationError(format!( + "Chat completion request field must be a string: {}", + key + )) + })?; + + if !value.trim().is_empty() { + request_image_overrides.push(key); + } + } + + if (request_images_enabled || !request_image_overrides.is_empty()) + && !self + .ios_policy + .capabilities + .llm + .chat_completion_features + .request_images + { + let suffix = if request_image_overrides.is_empty() { + String::new() + } else { + format!(" (used: {})", request_image_overrides.join(", ")) + }; + + return Err(ApplicationError::PermissionDenied(format!( + "iOS policy disabled capability: llm.chat_completion_features.request_images{}", + suffix + ))); + } + + Ok(()) + } + pub async fn get_status( &self, dto: ChatCompletionStatusRequestDto, @@ -63,6 +277,9 @@ impl ChatCompletionService { } let source = self.resolve_source(&dto.chat_completion_source)?; + self.ensure_chat_completion_source_allowed(source)?; + self.ensure_endpoint_overrides_allowed_for_status(source, &dto)?; + if source == ChatCompletionSource::VertexAi { return Ok(json!({ "bypass": true, @@ -86,6 +303,9 @@ impl ChatCompletionService { dto.get_string("chat_completion_source") .unwrap_or(OPENAI_SOURCE), )?; + self.ensure_chat_completion_source_allowed(source)?; + self.ensure_endpoint_overrides_allowed_for_payload(source, &dto.payload)?; + self.ensure_chat_completion_features_allowed(&dto.payload)?; let settings = self.load_tauritavern_settings().await?; @@ -138,6 +358,9 @@ impl ChatCompletionService { dto.get_string("chat_completion_source") .unwrap_or(OPENAI_SOURCE), )?; + self.ensure_chat_completion_source_allowed(source)?; + self.ensure_endpoint_overrides_allowed_for_payload(source, &dto.payload)?; + self.ensure_chat_completion_features_allowed(&dto.payload)?; let settings = self.load_tauritavern_settings().await?; diff --git a/src-tauri/src/domain/ios_policy.rs b/src-tauri/src/domain/ios_policy.rs new file mode 100644 index 00000000..cf9c12f2 --- /dev/null +++ b/src-tauri/src/domain/ios_policy.rs @@ -0,0 +1,650 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::domain::errors::DomainError; +use crate::domain::repositories::chat_completion_repository::ChatCompletionSource; + +pub const IOS_POLICY_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum IosPolicyScope { + Ios, + Ignored, +} + +impl IosPolicyScope { + pub fn for_current_platform() -> Self { + if cfg!(target_os = "ios") { + Self::Ios + } else { + Self::Ignored + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum IosPolicyProfile { + Full, + IosInternalFull, + IosExternalBeta, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum AllowlistMode { + All, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum AllowlistSetting { + Mode(AllowlistMode), + List(Vec), +} + +impl AllowlistSetting { + pub fn allows(&self, value: &str) -> bool { + match self { + Self::Mode(AllowlistMode::All) => true, + Self::List(items) => items.iter().any(|item| item == value), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyCapabilities { + pub extensions: IosPolicyExtensionCapabilities, + pub content: IosPolicyContentCapabilities, + pub updates: IosPolicyUpdateCapabilities, + pub prompts: IosPolicyPromptCapabilities, + pub llm: IosPolicyLlmCapabilities, + pub network: IosPolicyNetworkCapabilities, + pub scripting: IosPolicyScriptingCapabilities, + pub ai: IosPolicyImageGenerationCapabilities, + pub sync: IosPolicySyncCapabilities, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyExtensionCapabilities { + pub third_party_management: bool, + pub third_party_execution: bool, + pub system_allowlist: AllowlistSetting, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyContentCapabilities { + pub external_import: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyUpdateCapabilities { + pub startup_check: bool, + pub manual_check: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyPromptCapabilities { + pub nsfw_prompt: bool, + pub jailbreak_prompt: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyLlmCapabilities { + pub chat_completion_sources: IosPolicyChatCompletionSourceCapabilities, + pub chat_completion_features: IosPolicyChatCompletionFeatureCapabilities, + pub endpoint_overrides: bool, + pub text_completions: IosPolicyTextCompletionCapabilities, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyChatCompletionSourceCapabilities { + pub allowlist: AllowlistSetting, +} + +impl IosPolicyChatCompletionSourceCapabilities { + pub fn allows_source(&self, source: ChatCompletionSource) -> bool { + match &self.allowlist { + AllowlistSetting::Mode(AllowlistMode::All) => true, + AllowlistSetting::List(items) => items + .iter() + .any(|raw| ChatCompletionSource::parse(raw).is_some_and(|value| value == source)), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyChatCompletionFeatureCapabilities { + pub web_search: bool, + pub request_images: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyTextCompletionCapabilities { + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyNetworkCapabilities { + pub request_proxy: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyScriptingCapabilities { + pub prompt_injections: bool, + pub tool_registration: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyImageGenerationCapabilities { + pub image_generation: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicySyncCapabilities { + pub lan: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct IosPolicyActivationReport { + pub version: u32, + pub scope: IosPolicyScope, + pub profile: IosPolicyProfile, + pub capabilities: IosPolicyCapabilities, + pub overridden_capabilities: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyConfig { + pub version: u32, + pub profile: IosPolicyProfile, + #[serde(default)] + pub overrides: IosPolicyOverridesConfig, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyOverridesConfig { + #[serde(default)] + pub capabilities: IosPolicyCapabilitiesOverride, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyCapabilitiesOverride { + #[serde(default)] + pub extensions: IosPolicyExtensionCapabilitiesOverride, + #[serde(default)] + pub content: IosPolicyContentCapabilitiesOverride, + #[serde(default)] + pub updates: IosPolicyUpdateCapabilitiesOverride, + #[serde(default)] + pub prompts: IosPolicyPromptCapabilitiesOverride, + #[serde(default)] + pub llm: IosPolicyLlmCapabilitiesOverride, + #[serde(default)] + pub network: IosPolicyNetworkCapabilitiesOverride, + #[serde(default)] + pub scripting: IosPolicyScriptingCapabilitiesOverride, + #[serde(default)] + pub ai: IosPolicyImageGenerationCapabilitiesOverride, + #[serde(default)] + pub sync: IosPolicySyncCapabilitiesOverride, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyExtensionCapabilitiesOverride { + pub third_party_management: Option, + pub third_party_execution: Option, + pub system_allowlist: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyContentCapabilitiesOverride { + pub external_import: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyUpdateCapabilitiesOverride { + pub startup_check: Option, + pub manual_check: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyPromptCapabilitiesOverride { + pub nsfw_prompt: Option, + pub jailbreak_prompt: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyLlmCapabilitiesOverride { + #[serde(default)] + pub chat_completion_sources: IosPolicyChatCompletionSourceCapabilitiesOverride, + #[serde(default)] + pub chat_completion_features: IosPolicyChatCompletionFeatureCapabilitiesOverride, + pub endpoint_overrides: Option, + #[serde(default)] + pub text_completions: IosPolicyTextCompletionCapabilitiesOverride, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyChatCompletionSourceCapabilitiesOverride { + pub allowlist: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyChatCompletionFeatureCapabilitiesOverride { + pub web_search: Option, + pub request_images: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyTextCompletionCapabilitiesOverride { + pub enabled: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyNetworkCapabilitiesOverride { + pub request_proxy: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyScriptingCapabilitiesOverride { + pub prompt_injections: Option, + pub tool_registration: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicyImageGenerationCapabilitiesOverride { + pub image_generation: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +struct IosPolicySyncCapabilitiesOverride { + pub lan: Option, +} + +impl IosPolicyCapabilities { + pub fn baseline(profile: IosPolicyProfile) -> Self { + match profile { + IosPolicyProfile::Full => Self { + extensions: IosPolicyExtensionCapabilities { + third_party_management: true, + third_party_execution: true, + system_allowlist: AllowlistSetting::Mode(AllowlistMode::All), + }, + content: IosPolicyContentCapabilities { + external_import: true, + }, + updates: IosPolicyUpdateCapabilities { + startup_check: true, + manual_check: true, + }, + prompts: IosPolicyPromptCapabilities { + nsfw_prompt: true, + jailbreak_prompt: true, + }, + llm: IosPolicyLlmCapabilities { + chat_completion_sources: IosPolicyChatCompletionSourceCapabilities { + allowlist: AllowlistSetting::Mode(AllowlistMode::All), + }, + chat_completion_features: IosPolicyChatCompletionFeatureCapabilities { + web_search: true, + request_images: true, + }, + endpoint_overrides: true, + text_completions: IosPolicyTextCompletionCapabilities { enabled: true }, + }, + network: IosPolicyNetworkCapabilities { + request_proxy: true, + }, + scripting: IosPolicyScriptingCapabilities { + prompt_injections: true, + tool_registration: true, + }, + ai: IosPolicyImageGenerationCapabilities { + image_generation: true, + }, + sync: IosPolicySyncCapabilities { lan: true }, + }, + IosPolicyProfile::IosInternalFull => Self { + updates: IosPolicyUpdateCapabilities { + startup_check: false, + manual_check: true, + }, + ..Self::baseline(IosPolicyProfile::Full) + }, + IosPolicyProfile::IosExternalBeta => Self { + extensions: IosPolicyExtensionCapabilities { + third_party_management: false, + third_party_execution: false, + system_allowlist: AllowlistSetting::List(vec![ + "data-migration".to_string(), + "regex".to_string(), + "quick-reply".to_string(), + "tauritavern-version".to_string(), + "token-counter".to_string(), + ]), + }, + content: IosPolicyContentCapabilities { + external_import: false, + }, + updates: IosPolicyUpdateCapabilities { + startup_check: false, + manual_check: false, + }, + prompts: IosPolicyPromptCapabilities { + nsfw_prompt: false, + jailbreak_prompt: false, + }, + llm: IosPolicyLlmCapabilities { + chat_completion_sources: IosPolicyChatCompletionSourceCapabilities { + allowlist: AllowlistSetting::List(vec![ + "openai".to_string(), + "claude".to_string(), + "makersuite".to_string(), + ]), + }, + chat_completion_features: IosPolicyChatCompletionFeatureCapabilities { + web_search: false, + request_images: false, + }, + endpoint_overrides: false, + text_completions: IosPolicyTextCompletionCapabilities { enabled: false }, + }, + network: IosPolicyNetworkCapabilities { + request_proxy: false, + }, + scripting: IosPolicyScriptingCapabilities { + prompt_injections: false, + tool_registration: false, + }, + ai: IosPolicyImageGenerationCapabilities { + image_generation: false, + }, + sync: IosPolicySyncCapabilities { lan: false }, + }, + } + } + + fn validate(&self) -> Result<(), DomainError> { + if let AllowlistSetting::List(items) = &self.llm.chat_completion_sources.allowlist { + for raw_source in items { + if ChatCompletionSource::parse(raw_source).is_none() { + return Err(DomainError::InvalidData(format!( + "ios_policy: invalid llm.chat_completion_sources.allowlist entry: {}", + raw_source + ))); + } + } + } + + Ok(()) + } +} + +impl IosPolicyCapabilitiesOverride { + fn apply(self, capabilities: &mut IosPolicyCapabilities) -> Vec { + let mut overridden = Vec::new(); + + let ext = self.extensions; + if let Some(value) = ext.third_party_management { + capabilities.extensions.third_party_management = value; + overridden.push("extensions.third_party_management".to_string()); + } + if let Some(value) = ext.third_party_execution { + capabilities.extensions.third_party_execution = value; + overridden.push("extensions.third_party_execution".to_string()); + } + if let Some(value) = ext.system_allowlist { + capabilities.extensions.system_allowlist = value; + overridden.push("extensions.system_allowlist".to_string()); + } + + let content = self.content; + if let Some(value) = content.external_import { + capabilities.content.external_import = value; + overridden.push("content.external_import".to_string()); + } + + let updates = self.updates; + if let Some(value) = updates.startup_check { + capabilities.updates.startup_check = value; + overridden.push("updates.startup_check".to_string()); + } + if let Some(value) = updates.manual_check { + capabilities.updates.manual_check = value; + overridden.push("updates.manual_check".to_string()); + } + + let prompts = self.prompts; + if let Some(value) = prompts.nsfw_prompt { + capabilities.prompts.nsfw_prompt = value; + overridden.push("prompts.nsfw_prompt".to_string()); + } + if let Some(value) = prompts.jailbreak_prompt { + capabilities.prompts.jailbreak_prompt = value; + overridden.push("prompts.jailbreak_prompt".to_string()); + } + + let llm = self.llm; + let sources = llm.chat_completion_sources; + if let Some(value) = sources.allowlist { + capabilities.llm.chat_completion_sources.allowlist = value; + overridden.push("llm.chat_completion_sources.allowlist".to_string()); + } + + let features = llm.chat_completion_features; + if let Some(value) = features.web_search { + capabilities.llm.chat_completion_features.web_search = value; + overridden.push("llm.chat_completion_features.web_search".to_string()); + } + if let Some(value) = features.request_images { + capabilities.llm.chat_completion_features.request_images = value; + overridden.push("llm.chat_completion_features.request_images".to_string()); + } + + if let Some(value) = llm.endpoint_overrides { + capabilities.llm.endpoint_overrides = value; + overridden.push("llm.endpoint_overrides".to_string()); + } + + let text_completions = llm.text_completions; + if let Some(value) = text_completions.enabled { + capabilities.llm.text_completions.enabled = value; + overridden.push("llm.text_completions.enabled".to_string()); + } + + let network = self.network; + if let Some(value) = network.request_proxy { + capabilities.network.request_proxy = value; + overridden.push("network.request_proxy".to_string()); + } + + let scripting = self.scripting; + if let Some(value) = scripting.prompt_injections { + capabilities.scripting.prompt_injections = value; + overridden.push("scripting.prompt_injections".to_string()); + } + if let Some(value) = scripting.tool_registration { + capabilities.scripting.tool_registration = value; + overridden.push("scripting.tool_registration".to_string()); + } + + let ai = self.ai; + if let Some(value) = ai.image_generation { + capabilities.ai.image_generation = value; + overridden.push("ai.image_generation".to_string()); + } + + let sync = self.sync; + if let Some(value) = sync.lan { + capabilities.sync.lan = value; + overridden.push("sync.lan".to_string()); + } + + overridden + } +} + +pub fn resolve_ios_policy_activation_report( + scope: IosPolicyScope, + raw_policy: Option<&Value>, +) -> Result { + if scope == IosPolicyScope::Ignored { + let capabilities = IosPolicyCapabilities::baseline(IosPolicyProfile::Full); + return Ok(IosPolicyActivationReport { + version: IOS_POLICY_VERSION, + scope, + profile: IosPolicyProfile::Full, + capabilities, + overridden_capabilities: Vec::new(), + }); + } + + let Some(raw_policy) = raw_policy else { + let capabilities = IosPolicyCapabilities::baseline(IosPolicyProfile::Full); + return Ok(IosPolicyActivationReport { + version: IOS_POLICY_VERSION, + scope, + profile: IosPolicyProfile::Full, + capabilities, + overridden_capabilities: Vec::new(), + }); + }; + + let config: IosPolicyConfig = serde_json::from_value(raw_policy.clone()).map_err(|error| { + DomainError::InvalidData(format!("ios_policy: invalid schema: {}", error)) + })?; + + if config.version != IOS_POLICY_VERSION { + return Err(DomainError::InvalidData(format!( + "ios_policy: unsupported version {} (expected {})", + config.version, IOS_POLICY_VERSION + ))); + } + + let mut capabilities = IosPolicyCapabilities::baseline(config.profile); + let overridden_capabilities = config.overrides.capabilities.apply(&mut capabilities); + capabilities.validate()?; + + Ok(IosPolicyActivationReport { + version: config.version, + scope, + profile: config.profile, + capabilities, + overridden_capabilities, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_full_profile_when_ignored() { + let report = resolve_ios_policy_activation_report( + IosPolicyScope::Ignored, + Some(&serde_json::json!({ + "version": 1, + "profile": "ios_external_beta", + "overrides": { + "capabilities": { + "updates": { "manual_check": true } + } + } + })), + ) + .unwrap(); + + assert_eq!(report.scope, IosPolicyScope::Ignored); + assert_eq!(report.profile, IosPolicyProfile::Full); + assert!(report.capabilities.extensions.third_party_management); + } + + #[test] + fn rejects_unknown_keys() { + let error = resolve_ios_policy_activation_report( + IosPolicyScope::Ios, + Some(&serde_json::json!({ + "version": 1, + "profile": "full", + "overrides": { + "capabilities": { + "llm": { + "chat_completion_sources": { "allowlist": ["openai"] }, + "typo_key": true + } + } + } + })), + ) + .unwrap_err(); + + assert!( + error.to_string().contains("unknown field"), + "unexpected error: {}", + error + ); + } + + #[test] + fn applies_overrides_and_tracks_paths() { + let report = resolve_ios_policy_activation_report( + IosPolicyScope::Ios, + Some(&serde_json::json!({ + "version": 1, + "profile": "ios_external_beta", + "overrides": { + "capabilities": { + "updates": { "manual_check": true }, + "llm": { "endpoint_overrides": true } + } + } + })), + ) + .unwrap(); + + assert_eq!(report.profile, IosPolicyProfile::IosExternalBeta); + assert!(report.capabilities.updates.manual_check); + assert!(report.capabilities.llm.endpoint_overrides); + assert!( + report + .overridden_capabilities + .contains(&"updates.manual_check".to_string()) + ); + assert!( + report + .overridden_capabilities + .contains(&"llm.endpoint_overrides".to_string()) + ); + } +} diff --git a/src-tauri/src/domain/mod.rs b/src-tauri/src/domain/mod.rs index 6a0e0952..7906fee2 100644 --- a/src-tauri/src/domain/mod.rs +++ b/src-tauri/src/domain/mod.rs @@ -1,5 +1,6 @@ // Domain layer - contains core business entities and logic pub mod errors; +pub mod ios_policy; pub(crate) mod json_merge; pub mod models; pub mod repositories; diff --git a/src-tauri/src/domain/models/settings.rs b/src-tauri/src/domain/models/settings.rs index 4f556add..ae62e04f 100644 --- a/src-tauri/src/domain/models/settings.rs +++ b/src-tauri/src/domain/models/settings.rs @@ -1,5 +1,21 @@ use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde_json::{Map, Value, json}; + +fn default_ios_policy_seed() -> Option { + if !cfg!(target_os = "ios") { + return None; + } + + let profile = env!("TAURITAVERN_IOS_POLICY_PROFILE").trim(); + if profile.is_empty() { + return None; + } + + Some(json!({ + "version": crate::domain::ios_policy::IOS_POLICY_VERSION, + "profile": profile, + })) +} fn default_perf_profile() -> String { "auto".to_string() @@ -203,6 +219,14 @@ pub struct TauriTavernSettings { pub dynamic_theme: DynamicThemeSettings, #[serde(default = "default_model_settings")] pub models: ModelSettings, + /// iOS-only distribution policy (profile + capability overrides). + /// + /// NOTE: This field is intentionally stored as raw JSON to ensure: + /// - desktop builds can load settings exported from iOS even if the policy schema changes + /// - iOS builds can validate the schema strictly at runtime (fail-fast) without forcing + /// non-iOS platforms to parse/apply it. + #[serde(default)] + pub ios_policy: Option, } impl Default for TauriTavernSettings { @@ -222,6 +246,7 @@ impl Default for TauriTavernSettings { dev: DevLoggingSettings::default(), dynamic_theme: DynamicThemeSettings::default(), models: default_model_settings(), + ios_policy: default_ios_policy_seed(), } } } diff --git a/src-tauri/src/domain/repositories/chat_completion_repository.rs b/src-tauri/src/domain/repositories/chat_completion_repository.rs index 76ae92cb..e198ed73 100644 --- a/src-tauri/src/domain/repositories/chat_completion_repository.rs +++ b/src-tauri/src/domain/repositories/chat_completion_repository.rs @@ -43,6 +43,44 @@ impl ChatCompletionSource { _ => None, } } + + pub const fn key(self) -> &'static str { + match self { + Self::OpenAi => "openai", + Self::OpenRouter => "openrouter", + Self::Custom => "custom", + Self::Claude => "claude", + Self::Makersuite => "makersuite", + Self::VertexAi => "vertexai", + Self::DeepSeek => "deepseek", + Self::Cohere => "cohere", + Self::Groq => "groq", + Self::Moonshot => "moonshot", + Self::NanoGpt => "nanogpt", + Self::Chutes => "chutes", + Self::SiliconFlow => "siliconflow", + Self::Zai => "zai", + } + } + + pub const fn display_name(self) -> &'static str { + match self { + Self::OpenAi => "OpenAI", + Self::OpenRouter => "OpenRouter", + Self::Custom => "Custom OpenAI", + Self::Claude => "Claude", + Self::Makersuite => "Google Gemini", + Self::VertexAi => "Google Vertex AI", + Self::DeepSeek => "DeepSeek", + Self::Cohere => "Cohere", + Self::Groq => "Groq", + Self::Moonshot => "Moonshot AI", + Self::NanoGpt => "NanoGPT", + Self::Chutes => "Chutes", + Self::SiliconFlow => "SiliconFlow", + Self::Zai => "Z.AI (GLM)", + } + } } #[derive(Debug, Clone)] diff --git a/src-tauri/src/infrastructure/apis/http_chat_completion_repository/mod.rs b/src-tauri/src/infrastructure/apis/http_chat_completion_repository/mod.rs index 1af15133..1a6a9914 100644 --- a/src-tauri/src/infrastructure/apis/http_chat_completion_repository/mod.rs +++ b/src-tauri/src/infrastructure/apis/http_chat_completion_repository/mod.rs @@ -421,29 +421,23 @@ impl ChatCompletionRepository for HttpChatCompletionRepository { source: ChatCompletionSource, config: &ChatCompletionApiConfig, ) -> Result { + let source_name = source.display_name(); + match source { - ChatCompletionSource::OpenAi => openai::list_models(self, config, "OpenAI").await, - ChatCompletionSource::OpenRouter => { - openai::list_models(self, config, "OpenRouter").await - } - ChatCompletionSource::Custom => { - openai::list_models(self, config, "Custom OpenAI").await - } - ChatCompletionSource::DeepSeek => openai::list_models(self, config, "DeepSeek").await, + ChatCompletionSource::OpenAi + | ChatCompletionSource::OpenRouter + | ChatCompletionSource::Custom + | ChatCompletionSource::DeepSeek + | ChatCompletionSource::Groq + | ChatCompletionSource::Moonshot + | ChatCompletionSource::Chutes + | ChatCompletionSource::SiliconFlow + | ChatCompletionSource::Zai => openai::list_models(self, config, source_name).await, ChatCompletionSource::Cohere => cohere::list_models(self, config).await, - ChatCompletionSource::Groq => openai::list_models(self, config, "Groq").await, - ChatCompletionSource::Moonshot => { - openai::list_models(self, config, "Moonshot AI").await - } ChatCompletionSource::NanoGpt => { - openai::list_models_with_path(self, config, "NanoGPT", "/models?detailed=true") + openai::list_models_with_path(self, config, source_name, "/models?detailed=true") .await } - ChatCompletionSource::Chutes => openai::list_models(self, config, "Chutes").await, - ChatCompletionSource::SiliconFlow => { - openai::list_models(self, config, "SiliconFlow").await - } - ChatCompletionSource::Zai => openai::list_models(self, config, "Z.AI (GLM)").await, ChatCompletionSource::Claude => claude::list_models(self, config).await, ChatCompletionSource::Makersuite => makersuite::list_models(self, config).await, ChatCompletionSource::VertexAi => vertexai::list_models(self, config).await, @@ -457,12 +451,19 @@ impl ChatCompletionRepository for HttpChatCompletionRepository { endpoint_path: &str, payload: &Value, ) -> Result { + let source_name = source.display_name(); + match source { - ChatCompletionSource::OpenAi => { - openai::generate(self, config, endpoint_path, payload, "OpenAI").await - } - ChatCompletionSource::OpenRouter => { - openai::generate(self, config, endpoint_path, payload, "OpenRouter").await + ChatCompletionSource::OpenAi + | ChatCompletionSource::OpenRouter + | ChatCompletionSource::DeepSeek + | ChatCompletionSource::Groq + | ChatCompletionSource::Moonshot + | ChatCompletionSource::NanoGpt + | ChatCompletionSource::Chutes + | ChatCompletionSource::SiliconFlow + | ChatCompletionSource::Zai => { + openai::generate(self, config, endpoint_path, payload, source_name).await } ChatCompletionSource::Custom => { if endpoint_path == "/responses" { @@ -494,35 +495,14 @@ impl ChatCompletionRepository for HttpChatCompletionRepository { ) .await } else { - openai::generate(self, config, endpoint_path, payload, "Custom OpenAI").await + openai::generate(self, config, endpoint_path, payload, source_name).await } } - ChatCompletionSource::DeepSeek => { - openai::generate(self, config, endpoint_path, payload, "DeepSeek").await - } ChatCompletionSource::Cohere => { cohere::generate(self, config, endpoint_path, payload).await } - ChatCompletionSource::Groq => { - openai::generate(self, config, endpoint_path, payload, "Groq").await - } - ChatCompletionSource::Moonshot => { - openai::generate(self, config, endpoint_path, payload, "Moonshot AI").await - } - ChatCompletionSource::NanoGpt => { - openai::generate(self, config, endpoint_path, payload, "NanoGPT").await - } - ChatCompletionSource::Chutes => { - openai::generate(self, config, endpoint_path, payload, "Chutes").await - } - ChatCompletionSource::SiliconFlow => { - openai::generate(self, config, endpoint_path, payload, "SiliconFlow").await - } - ChatCompletionSource::Zai => { - openai::generate(self, config, endpoint_path, payload, "Z.AI (GLM)").await - } ChatCompletionSource::Claude => { - claude::generate(self, config, endpoint_path, payload, "Claude", true).await + claude::generate(self, config, endpoint_path, payload, source_name, true).await } ChatCompletionSource::Makersuite => { makersuite::generate(self, config, endpoint_path, payload).await @@ -542,26 +522,24 @@ impl ChatCompletionRepository for HttpChatCompletionRepository { sender: ChatCompletionStreamSender, cancel: ChatCompletionCancelReceiver, ) -> Result<(), DomainError> { + let source_name = source.display_name(); + match source { - ChatCompletionSource::OpenAi => { + ChatCompletionSource::OpenAi + | ChatCompletionSource::OpenRouter + | ChatCompletionSource::DeepSeek + | ChatCompletionSource::Groq + | ChatCompletionSource::Moonshot + | ChatCompletionSource::NanoGpt + | ChatCompletionSource::Chutes + | ChatCompletionSource::SiliconFlow + | ChatCompletionSource::Zai => { openai::generate_stream( self, config, endpoint_path, payload, - "OpenAI", - sender, - cancel, - ) - .await - } - ChatCompletionSource::OpenRouter => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "OpenRouter", + source_name, sender, cancel, ) @@ -608,107 +586,23 @@ impl ChatCompletionRepository for HttpChatCompletionRepository { config, endpoint_path, payload, - "Custom OpenAI", + source_name, sender, cancel, ) .await } } - ChatCompletionSource::DeepSeek => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "DeepSeek", - sender, - cancel, - ) - .await - } ChatCompletionSource::Cohere => { cohere::generate_stream(self, config, endpoint_path, payload, sender, cancel).await } - ChatCompletionSource::Groq => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "Groq", - sender, - cancel, - ) - .await - } - ChatCompletionSource::Moonshot => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "Moonshot AI", - sender, - cancel, - ) - .await - } - ChatCompletionSource::NanoGpt => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "NanoGPT", - sender, - cancel, - ) - .await - } - ChatCompletionSource::Chutes => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "Chutes", - sender, - cancel, - ) - .await - } - ChatCompletionSource::SiliconFlow => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "SiliconFlow", - sender, - cancel, - ) - .await - } - ChatCompletionSource::Zai => { - openai::generate_stream( - self, - config, - endpoint_path, - payload, - "Z.AI (GLM)", - sender, - cancel, - ) - .await - } ChatCompletionSource::Claude => { claude::generate_stream( self, config, endpoint_path, payload, - "Claude", + source_name, sender, cancel, true, diff --git a/src-tauri/src/infrastructure/logging/llm_api_logs.rs b/src-tauri/src/infrastructure/logging/llm_api_logs.rs index 42daea2c..d9463f68 100644 --- a/src-tauri/src/infrastructure/logging/llm_api_logs.rs +++ b/src-tauri/src/infrastructure/logging/llm_api_logs.rs @@ -371,7 +371,7 @@ impl ChatCompletionRepository for LoggingChatCompletionRepository { timestamp_ms: started_at_ms, level, ok, - source: source.to_string(), + source: source.key().to_string(), model, endpoint, duration_ms, @@ -495,7 +495,7 @@ impl ChatCompletionRepository for LoggingChatCompletionRepository { timestamp_ms: started_at_ms, level, ok, - source: source.to_string(), + source: source.key().to_string(), model, endpoint, duration_ms, @@ -523,28 +523,6 @@ fn stream_readable_source( source } -impl ToString for ChatCompletionSource { - fn to_string(&self) -> String { - match self { - ChatCompletionSource::OpenAi => "openai", - ChatCompletionSource::OpenRouter => "openrouter", - ChatCompletionSource::Custom => "custom", - ChatCompletionSource::Claude => "claude", - ChatCompletionSource::Makersuite => "makersuite", - ChatCompletionSource::VertexAi => "vertexai", - ChatCompletionSource::DeepSeek => "deepseek", - ChatCompletionSource::Cohere => "cohere", - ChatCompletionSource::Groq => "groq", - ChatCompletionSource::Moonshot => "moonshot", - ChatCompletionSource::NanoGpt => "nanogpt", - ChatCompletionSource::Chutes => "chutes", - ChatCompletionSource::SiliconFlow => "siliconflow", - ChatCompletionSource::Zai => "zai", - } - .to_string() - } -} - struct StreamReadableCollector { source: ChatCompletionSource, buffer: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd10cb53..eb7e8052 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,10 +150,24 @@ pub async fn run() { app.manage(user_dirs.clone()); let tauritavern_settings = load_tauritavern_settings(&runtime_paths.data_root)?; + let ios_policy = crate::domain::ios_policy::resolve_ios_policy_activation_report( + crate::domain::ios_policy::IosPolicyScope::for_current_platform(), + tauritavern_settings.ios_policy.as_ref(), + )?; let thumbnail_policy = std::sync::Arc::new(ThumbnailEndpointPolicy::new( tauritavern_settings.avatar_persona_original_images_enabled, )); app.manage(thumbnail_policy.clone()); + + if ios_policy.scope == crate::domain::ios_policy::IosPolicyScope::Ios + && tauritavern_settings.request_proxy.enabled + && !ios_policy.capabilities.network.request_proxy + { + return Err(Box::new(crate::domain::errors::DomainError::InvalidData( + "iOS policy disabled capability: network.request_proxy".to_string(), + ))); + } + http_client_pool.apply_request_proxy_settings(&tauritavern_settings.request_proxy)?; llm_api_log_store.apply_settings(tauritavern_settings.dev.effective_llm_api_keep()); let _main_window = diff --git a/src-tauri/src/presentation/commands/bootstrap_commands.rs b/src-tauri/src/presentation/commands/bootstrap_commands.rs index 69cac67f..14a27a7a 100644 --- a/src-tauri/src/presentation/commands/bootstrap_commands.rs +++ b/src-tauri/src/presentation/commands/bootstrap_commands.rs @@ -74,6 +74,7 @@ pub async fn get_bootstrap_snapshot( )?; Ok(BootstrapSnapshotDto { + ios_policy: app_state.ios_policy.clone(), settings, characters, groups, diff --git a/src-tauri/src/presentation/commands/content_commands.rs b/src-tauri/src/presentation/commands/content_commands.rs index ca10dade..20bb237f 100644 --- a/src-tauri/src/presentation/commands/content_commands.rs +++ b/src-tauri/src/presentation/commands/content_commands.rs @@ -6,7 +6,9 @@ use tauri::State; use crate::app::AppState; use crate::infrastructure::http_client_pool::{HttpClientPool, HttpClientProfile}; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; #[tauri::command] @@ -48,10 +50,17 @@ pub struct ExternalImportDownloadResult { #[tauri::command] pub async fn download_external_import_url( url: String, + app_state: State<'_, Arc>, http_clients: State<'_, Arc>, ) -> Result { log_command("download_external_import_url"); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.content.external_import, + "content.external_import", + )?; + let parsed_url = reqwest::Url::parse(url.trim()) .map_err(|_| CommandError::BadRequest("Invalid import URL".to_string()))?; diff --git a/src-tauri/src/presentation/commands/extension_commands.rs b/src-tauri/src/presentation/commands/extension_commands.rs index 700b937b..c2260c7c 100644 --- a/src-tauri/src/presentation/commands/extension_commands.rs +++ b/src-tauri/src/presentation/commands/extension_commands.rs @@ -4,11 +4,14 @@ use tauri::State; use crate::app::AppState; use crate::domain::errors::DomainError; +use crate::domain::ios_policy::IosPolicyScope; use crate::domain::models::extension::{ Extension, ExtensionInstallResult, ExtensionUpdateResult, ExtensionVersion, }; use crate::infrastructure::logging::logger; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; #[tauri::command] @@ -17,11 +20,45 @@ pub async fn get_extensions( ) -> Result, CommandError> { log_command("get_extensions"); - app_state + let mut extensions = app_state .extension_service .get_extensions() .await - .map_err(map_command_error("Failed to get extensions")) + .map_err(map_command_error("Failed to get extensions"))?; + + if app_state.ios_policy.scope == IosPolicyScope::Ios { + let allow_third_party = app_state + .ios_policy + .capabilities + .extensions + .third_party_execution; + let system_allowlist = &app_state + .ios_policy + .capabilities + .extensions + .system_allowlist; + + let before = extensions.len(); + extensions.retain(|extension| match extension.extension_type { + crate::domain::models::extension::ExtensionType::System => { + system_allowlist.allows(&extension.name) + } + crate::domain::models::extension::ExtensionType::Local + | crate::domain::models::extension::ExtensionType::Global => allow_third_party, + }); + + let filtered = before.saturating_sub(extensions.len()); + if filtered > 0 { + tracing::info!( + filtered, + before, + profile = ?app_state.ios_policy.profile, + "iOS policy filtered extensions during discovery" + ); + } + } + + Ok(extensions) } #[tauri::command] @@ -33,6 +70,16 @@ pub async fn install_extension( ) -> Result { log_command(format!("install_extension {}", url)); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state + .ios_policy + .capabilities + .extensions + .third_party_management, + "extensions.third_party_management", + )?; + app_state .extension_service .install_extension(&url, global, branch) @@ -56,6 +103,16 @@ pub async fn update_extension( ) -> Result { log_command(format!("update_extension {}", extension_name)); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state + .ios_policy + .capabilities + .extensions + .third_party_management, + "extensions.third_party_management", + )?; + app_state .extension_service .update_extension(&extension_name, global) @@ -71,6 +128,16 @@ pub async fn delete_extension( ) -> Result<(), CommandError> { log_command(format!("delete_extension {}", extension_name)); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state + .ios_policy + .capabilities + .extensions + .third_party_management, + "extensions.third_party_management", + )?; + app_state .extension_service .delete_extension(&extension_name, global) @@ -86,6 +153,16 @@ pub async fn get_extension_version( ) -> Result { log_command(format!("get_extension_version {}", extension_name)); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state + .ios_policy + .capabilities + .extensions + .third_party_management, + "extensions.third_party_management", + )?; + app_state .extension_service .get_extension_version(&extension_name, global) @@ -105,6 +182,16 @@ pub async fn move_extension( extension_name, source, destination )); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state + .ios_policy + .capabilities + .extensions + .third_party_management, + "extensions.third_party_management", + )?; + app_state .extension_service .move_extension(&extension_name, &source, &destination) diff --git a/src-tauri/src/presentation/commands/helpers.rs b/src-tauri/src/presentation/commands/helpers.rs index 2674903c..9a946c4c 100644 --- a/src-tauri/src/presentation/commands/helpers.rs +++ b/src-tauri/src/presentation/commands/helpers.rs @@ -3,6 +3,20 @@ use std::fmt::Display; use crate::infrastructure::logging::logger; use crate::presentation::errors::CommandError; +pub fn ensure_ios_policy_allows( + ios_policy: &crate::domain::ios_policy::IosPolicyActivationReport, + allowed: bool, + capability: &'static str, +) -> Result<(), CommandError> { + if ios_policy.scope == crate::domain::ios_policy::IosPolicyScope::Ios && !allowed { + return Err(CommandError::Unauthorized(format!( + "iOS policy disabled capability: {capability}" + ))); + } + + Ok(()) +} + pub fn log_command(command: impl AsRef) { logger::debug(&format!("Command: {}", command.as_ref())); } diff --git a/src-tauri/src/presentation/commands/lan_sync_commands.rs b/src-tauri/src/presentation/commands/lan_sync_commands.rs index 49cdb77f..63014c3d 100644 --- a/src-tauri/src/presentation/commands/lan_sync_commands.rs +++ b/src-tauri/src/presentation/commands/lan_sync_commands.rs @@ -5,14 +5,25 @@ use tauri::State; use crate::app::AppState; use crate::domain::models::lan_sync::{LanSyncPairedDevice, LanSyncStatus, LanSyncSyncMode}; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; +fn ensure_lan_sync_allowed(app_state: &AppState) -> Result<(), CommandError> { + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.sync.lan, + "sync.lan", + ) +} + #[tauri::command] pub async fn lan_sync_get_status( app_state: State<'_, Arc>, ) -> Result { log_command("lan_sync_get_status"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -26,6 +37,7 @@ pub async fn lan_sync_start_server( app_state: State<'_, Arc>, ) -> Result { log_command("lan_sync_start_server"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -37,6 +49,7 @@ pub async fn lan_sync_start_server( #[tauri::command] pub async fn lan_sync_stop_server(app_state: State<'_, Arc>) -> Result<(), CommandError> { log_command("lan_sync_stop_server"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -59,6 +72,7 @@ pub async fn lan_sync_enable_pairing( address: Option, ) -> Result { log_command("lan_sync_enable_pairing"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -79,6 +93,7 @@ pub async fn lan_sync_get_pairing_info( address: String, ) -> Result { log_command("lan_sync_get_pairing_info"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -120,6 +135,7 @@ pub async fn lan_sync_request_pairing( pair_uri: String, ) -> Result { log_command("lan_sync_request_pairing"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -136,6 +152,7 @@ pub async fn lan_sync_confirm_pairing( accept: bool, ) -> Result<(), CommandError> { log_command("lan_sync_confirm_pairing"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -149,6 +166,7 @@ pub async fn lan_sync_list_devices( app_state: State<'_, Arc>, ) -> Result, CommandError> { log_command("lan_sync_list_devices"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -169,6 +187,7 @@ pub async fn lan_sync_remove_device( device_id: String, ) -> Result<(), CommandError> { log_command("lan_sync_remove_device"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -183,6 +202,7 @@ pub async fn lan_sync_sync_from_device( device_id: String, ) -> Result<(), CommandError> { log_command("lan_sync_sync_from_device"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -197,6 +217,7 @@ pub async fn lan_sync_push_to_device( device_id: String, ) -> Result<(), CommandError> { log_command("lan_sync_push_to_device"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -212,6 +233,7 @@ pub async fn lan_sync_set_sync_mode( persist: bool, ) -> Result<(), CommandError> { log_command("lan_sync_set_sync_mode"); + ensure_lan_sync_allowed(&app_state)?; app_state .lan_sync_service @@ -225,6 +247,7 @@ pub async fn lan_sync_clear_sync_mode_override( app_state: State<'_, Arc>, ) -> Result<(), CommandError> { log_command("lan_sync_clear_sync_mode_override"); + ensure_lan_sync_allowed(&app_state)?; app_state.lan_sync_service.clear_sync_mode_override().await; Ok(()) diff --git a/src-tauri/src/presentation/commands/settings_commands.rs b/src-tauri/src/presentation/commands/settings_commands.rs index 2d3fbede..246c6095 100644 --- a/src-tauri/src/presentation/commands/settings_commands.rs +++ b/src-tauri/src/presentation/commands/settings_commands.rs @@ -10,7 +10,9 @@ use crate::application::dto::settings_dto::{ use crate::domain::models::settings::RequestProxySettings; use crate::infrastructure::http_client_pool::HttpClientPool; use crate::infrastructure::logging::llm_api_logs::LlmApiLogStore; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; use crate::presentation::web_resources::thumbnail_endpoint::ThumbnailEndpointPolicy; @@ -42,6 +44,14 @@ pub async fn update_tauritavern_settings( let request_proxy_settings: Option = dto.request_proxy.clone().map(Into::into); if let Some(settings) = request_proxy_settings.as_ref() { + if settings.enabled { + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.network.request_proxy, + "network.request_proxy", + )?; + } + HttpClientPool::validate_request_proxy_settings(settings) .map_err(map_command_error("Invalid request proxy settings"))?; } @@ -82,6 +92,14 @@ pub async fn update_tauritavern_settings( let request_proxy_settings: Option = dto.request_proxy.clone().map(Into::into); if let Some(settings) = request_proxy_settings.as_ref() { + if settings.enabled { + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.network.request_proxy, + "network.request_proxy", + )?; + } + HttpClientPool::validate_request_proxy_settings(settings) .map_err(map_command_error("Invalid request proxy settings"))?; } diff --git a/src-tauri/src/presentation/commands/stable_diffusion_commands.rs b/src-tauri/src/presentation/commands/stable_diffusion_commands.rs index b058047a..3ed6099b 100644 --- a/src-tauri/src/presentation/commands/stable_diffusion_commands.rs +++ b/src-tauri/src/presentation/commands/stable_diffusion_commands.rs @@ -5,7 +5,9 @@ use tauri::State; use crate::app::AppState; use crate::application::dto::stable_diffusion_dto::SdRouteResponseDto; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; #[tauri::command] @@ -19,6 +21,12 @@ pub async fn sd_handle( validate_request_id(&request_id)?; log_command(format!("sd_handle {} {}", request_id, path)); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.ai.image_generation, + "ai.image_generation", + )?; + app_state .stable_diffusion_service .handle_request(&request_id, path, body) diff --git a/src-tauri/src/presentation/commands/update_commands.rs b/src-tauri/src/presentation/commands/update_commands.rs index cabdc58d..a71bff7a 100644 --- a/src-tauri/src/presentation/commands/update_commands.rs +++ b/src-tauri/src/presentation/commands/update_commands.rs @@ -4,7 +4,9 @@ use tauri::State; use crate::app::AppState; use crate::domain::models::update::UpdateCheckResult; -use crate::presentation::commands::helpers::{log_command, map_command_error}; +use crate::presentation::commands::helpers::{ + ensure_ios_policy_allows, log_command, map_command_error, +}; use crate::presentation::errors::CommandError; #[tauri::command] @@ -13,6 +15,12 @@ pub async fn check_for_update( ) -> Result { log_command("check_for_update"); + ensure_ios_policy_allows( + &app_state.ios_policy, + app_state.ios_policy.capabilities.updates.manual_check, + "updates.manual_check", + )?; + app_state .update_service .check_for_update() diff --git a/src/locales/zh-cn.json b/src/locales/zh-cn.json index eecbe7d0..fadf0093 100644 --- a/src/locales/zh-cn.json +++ b/src/locales/zh-cn.json @@ -2341,6 +2341,7 @@ "When enabled, previews in the newest message automatically replace the current last message view.": "启用后,最新消息中的预览会自动替换当前最后一条消息视图。", "Data Migration": "数据迁移", "Import a SillyTavern data zip archive and migrate it to TauriTavern.": "导入 SillyTavern 的 data 压缩包并迁移到 TauriTavern。", + "Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.": "从其他设备导入 TauriTavern 数据压缩包并迁移到当前 TauriTavern。", "Imported user handles are normalized to default-user.": "导入的用户标识会统一规范为 default-user。", "Import Data Zip": "导入数据压缩包", "Export Current Data": "导出当前数据", diff --git a/src/locales/zh-tw.json b/src/locales/zh-tw.json index 913949f5..12ee21f0 100644 --- a/src/locales/zh-tw.json +++ b/src/locales/zh-tw.json @@ -2791,6 +2791,7 @@ "When enabled, previews in the newest message automatically replace the current last message view.": "啟用後,最新訊息中的預覽會自動取代目前最後一則訊息的顯示。", "Data Migration": "資料遷移", "Import a SillyTavern data zip archive and migrate it to TauriTavern.": "匯入 SillyTavern 的 data 壓縮檔並遷移至 TauriTavern。", + "Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.": "從其他裝置匯入 TauriTavern 的 data 壓縮檔並遷移至此 TauriTavern。", "Imported user handles are normalized to default-user.": "匯入的使用者代號會統一正規化為 default-user。", "Import Data Zip": "匯入資料壓縮檔", "Export Current Data": "匯出目前資料", diff --git a/src/script.js b/src/script.js index a475d898..c243a1f0 100644 --- a/src/script.js +++ b/src/script.js @@ -45,6 +45,8 @@ import { focusChatInput, installChatInputFocusKeeper, } from './scripts/chat-input-focus.js'; +import { getActiveIosPolicyCapabilities } from './scripts/tauritavern/ios-policy.js'; +import { applyIosPolicyUiProjection } from './scripts/tauritavern/ios-policy-ui.js'; import { humanizedDateTime, favsToHotswap, getMessageTimeStamp, dragElement, isMobile, initRossMods } from './scripts/RossAscends-mods.js'; import { userStatsHandler, statMesProcess, initStats } from './scripts/stats.js'; @@ -907,6 +909,15 @@ async function firstLoadInit() { const clientVersionPromise = getClientVersion(); await initSecrets(); const bootstrapSnapshot = await bootstrapPromise; + if (bootstrapSnapshot?.ios_policy?.scope === 'ios') { + if (!window.__TAURITAVERN__ || typeof window.__TAURITAVERN__ !== 'object') { + throw new Error('[TauriTavern][iOSPolicy] Host ABI is unavailable (window.__TAURITAVERN__).'); + } + window.__TAURITAVERN__.iosPolicy = bootstrapSnapshot.ios_policy; + } else if (window.__TAURITAVERN__ && typeof window.__TAURITAVERN__ === 'object') { + window.__TAURITAVERN__.iosPolicy = bootstrapSnapshot.ios_policy; + } + applyIosPolicyUiProjection(); const extensionsEnabled = Boolean(bootstrapSnapshot.settings?.enable_extensions) && bootstrapSnapshot.settings?.result != 'file not find' && Boolean(bootstrapSnapshot.settings?.settings); @@ -8475,6 +8486,12 @@ async function applySettingsSnapshot(data) { settings.main_api = 'openai'; } + const iosCaps = getActiveIosPolicyCapabilities(); + if (iosCaps && iosCaps.llm?.text_completions?.enabled === false && settings.main_api === 'textgenerationwebui') { + settings.main_api = 'openai'; + globalThis.toastr?.warning?.('Text Completion is disabled by your iOS policy. Switched to Chat Completion.'); + } + main_api = settings.main_api; $('#main_api').val(main_api); $(`#main_api option[value=${main_api}]`).attr('selected', 'true'); @@ -13130,6 +13147,12 @@ jQuery(async function () { }); $(document).on('click', '.external_import_button, #external_import_button', async () => { + const iosCaps = getActiveIosPolicyCapabilities(); + if (iosCaps && iosCaps.content?.external_import === false) { + globalThis.toastr?.error?.('External import is disabled by your iOS policy.'); + return; + } + const html = await renderTemplateAsync('importCharacters'); const input = await callGenericPopup(html, POPUP_TYPE.INPUT, '', { allowVerticalScrolling: true, wider: true, okButton: $('#popup_template').attr('popup-button-import'), rows: 4 }); diff --git a/src/scripts/extensions/data-migration/index.js b/src/scripts/extensions/data-migration/index.js index 89b8b8a4..f6923b3f 100644 --- a/src/scripts/extensions/data-migration/index.js +++ b/src/scripts/extensions/data-migration/index.js @@ -1,7 +1,8 @@ import { renderExtensionTemplateAsync } from '../../extensions.js'; -import { t } from '../../i18n.js'; +import { t, translate } from '../../i18n.js'; import { Popup } from '../../popup.js'; import { isAndroidRuntime, isIosRuntime } from '../../util/mobile-runtime.js'; +import { getActiveIosPolicyActivationReport } from '../../tauritavern/ios-policy.js'; const MODULE_NAME = 'data-migration'; const JOB_POLL_INTERVAL_MS = 1200; @@ -9,6 +10,9 @@ const TERMINAL_JOB_STATES = new Set(['completed', 'failed', 'cancelled']); const PREPARING_PROGRESS_INTERVAL_MS = 320; const PREPARING_PROGRESS_MAX_BEFORE_JOB = 96; +const SILLYTAVERN_MIGRATION_COPY_KEY = 'Import a SillyTavern data zip archive and migrate it to TauriTavern.'; +const TAURITAVERN_MIGRATION_COPY_KEY = 'Import a TauriTavern data zip archive from another device and migrate it to this TauriTavern.'; + const jobState = { jobId: '', starting: false, @@ -592,6 +596,17 @@ jQuery(async () => { $('#data_migration_container').append(html); refreshControls(); + const iosPolicy = getActiveIosPolicyActivationReport(); + if (iosPolicy?.profile === 'ios_external_beta') { + const description = document.querySelector(`#data_migration_settings .extensions_info[data-i18n="${CSS.escape(SILLYTAVERN_MIGRATION_COPY_KEY)}"]`); + if (!(description instanceof HTMLElement)) { + throw new Error('[TauriTavern][iOSPolicy] Data migration description element not found'); + } + + description.dataset.i18n = TAURITAVERN_MIGRATION_COPY_KEY; + description.textContent = translate(TAURITAVERN_MIGRATION_COPY_KEY); + } + $('#data_migration_import_button').on('click', onImportButtonClick); $('#data_migration_import_input').on('change', onImportInputChange); $('#data_migration_export_button').on('click', onExportClick); diff --git a/src/scripts/extensions/tauritavern-version/index.js b/src/scripts/extensions/tauritavern-version/index.js index 23c39ed8..e51a9341 100644 --- a/src/scripts/extensions/tauritavern-version/index.js +++ b/src/scripts/extensions/tauritavern-version/index.js @@ -32,6 +32,15 @@ let startupUpdatePopupShown = false; let tauriTavernSettingsCache = null; let tauriTavernSettingsPromise = null; +function resolveIosUpdateCapabilities() { + const report = window.__TAURITAVERN__?.iosPolicy; + if (!report || report.scope !== 'ios') { + return null; + } + + return report.capabilities?.updates ?? null; +} + function localize(key, fallback) { return translate(fallback, key); } @@ -244,6 +253,17 @@ async function onCheckUpdateClick() { return; } + const updateCaps = resolveIosUpdateCapabilities(); + if (updateCaps && updateCaps.manual_check === false) { + globalThis.toastr?.error?.( + localize( + 'ttv_version.check_update_disabled', + 'Manual update checking is disabled by your iOS policy.', + ), + ); + return; + } + const $btn = $('#tauritavern_check_update'); const $icon = $btn.find('i'); const $text = $btn.find('span'); @@ -435,6 +455,12 @@ async function runStartupUpdateCheck() { } eventSource.once(event_types.APP_READY, () => { + const updateCaps = resolveIosUpdateCapabilities(); + if (updateCaps && updateCaps.startup_check === false) { + console.debug('Startup update check skipped: disabled by iOS policy'); + return; + } + void runStartupUpdateCheck(); }); @@ -447,7 +473,24 @@ jQuery(async () => { const html = await renderExtensionTemplateAsync(MODULE_NAME, 'settings', LINKS); container.append(html); $('#tauritavern_export_debug_bundle').on('click', () => void onExportDebugBundleClick()); - $('#tauritavern_check_update').on('click', onCheckUpdateClick); + const updateCaps = resolveIosUpdateCapabilities(); + if (updateCaps && updateCaps.manual_check === false) { + $('#tauritavern_check_update').remove(); + + const compatRow = document.getElementById('ttv-compat-row'); + if (!(compatRow instanceof HTMLElement)) { + throw new Error('[TauriTavern][iOSPolicy] ttv-compat-row not found'); + } + compatRow.hidden = true; + + const discordLink = document.getElementById('ttv-discord-link'); + if (!(discordLink instanceof HTMLElement)) { + throw new Error('[TauriTavern][iOSPolicy] ttv-discord-link not found'); + } + discordLink.hidden = true; + } else { + $('#tauritavern_check_update').on('click', onCheckUpdateClick); + } $('#tauritavern_update_dismiss').on('click', hideUpdateResult); container.on('click', 'a[target="_blank"]', onExternalLinkClick); diff --git a/src/scripts/extensions/tauritavern-version/settings.html b/src/scripts/extensions/tauritavern-version/settings.html index 18165316..317eaf94 100644 --- a/src/scripts/extensions/tauritavern-version/settings.html +++ b/src/scripts/extensions/tauritavern-version/settings.html @@ -10,7 +10,7 @@ App Version Loading... -
+
Compatibility Baseline Loading...
@@ -29,7 +29,7 @@ GitHub Repository - + Discord Update Channel diff --git a/src/scripts/openai.js b/src/scripts/openai.js index 38125be0..9f9cc0be 100644 --- a/src/scripts/openai.js +++ b/src/scripts/openai.js @@ -32,6 +32,7 @@ import { } from '../script.js'; import { getGroupNames, selected_group } from './group-chats.js'; import { extension_prompt_roles, extension_prompt_types } from './extension-prompts.js'; +import { allowlistSettingAllows, getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js'; import { chatCompletionDefaultPrompts, @@ -4101,6 +4102,60 @@ function syncChatCompletionSourceSelector() { $('#chat_completion_source').find(`option[value="${CSS.escape(value)}"]`).prop('selected', true); } +function normalizeIosPolicyChatCompletionSourceKey(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized.startsWith('custom_')) { + return chat_completion_sources.CUSTOM; + } + return normalized; +} + +function enforceIosPolicyChatCompletionSourceSelector() { + const caps = getActiveIosPolicyCapabilities(); + if (!caps) { + return; + } + + const select = document.getElementById('chat_completion_source'); + if (!(select instanceof HTMLSelectElement)) { + throw new Error('[TauriTavern][iOSPolicy] Chat completion source selector not found'); + } + + const allowlist = caps.llm?.chat_completion_sources?.allowlist; + const endpointOverridesAllowed = caps.llm?.endpoint_overrides !== false; + + const isAllowed = (value) => { + const key = normalizeIosPolicyChatCompletionSourceKey(value); + if (!key) { + return false; + } + + if (!endpointOverridesAllowed && key === chat_completion_sources.CUSTOM) { + return false; + } + + return allowlistSettingAllows(allowlist, key); + }; + + const currentValue = String(select.value || '').trim(); + if (currentValue && isAllowed(currentValue)) { + return; + } + + const fallback = Array.from(select.options) + .map((option) => option.value) + .find(isAllowed); + + if (!fallback) { + throw new Error('[TauriTavern][iOSPolicy] No allowed chat completion source remains after applying policy'); + } + + if (fallback !== currentValue) { + select.value = fallback; + globalThis.toastr?.warning?.('Chat Completion Source was reset due to your iOS policy.'); + } +} + function normalizeCustomEndpointBaseUrl(value) { const url = String(value || '').trim(); return url ? url.replace(/\/+$/, '') : ''; @@ -4243,6 +4298,7 @@ function loadOpenAISettings(data, settings) { $('#openrouter_providers_chat').trigger('change'); $('#openrouter_quantizations_chat').trigger('change'); syncChatCompletionSourceSelector(); + enforceIosPolicyChatCompletionSourceSelector(); $('#chat_completion_source').trigger('change'); updateCustomEndpointPreview(); } @@ -6688,6 +6744,7 @@ export function initOpenAI() { }); $('#chat_completion_source').on('change', function () { + enforceIosPolicyChatCompletionSourceSelector(); cancelStatusCheck('Chat Completion source changed'); model_list = []; applyChatCompletionSourceSelection(String($(this).find(':selected').val())); diff --git a/src/scripts/slash-commands.js b/src/scripts/slash-commands.js index 30575937..71c1acd8 100644 --- a/src/scripts/slash-commands.js +++ b/src/scripts/slash-commands.js @@ -78,6 +78,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js'; import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js'; import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; import { SlashCommandEnumValue, enumTypes } from './slash-commands/SlashCommandEnumValue.js'; +import { getActiveIosPolicyCapabilities } from './tauritavern/ios-policy.js'; import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; @@ -232,6 +233,7 @@ function setupConnectAPIMap() { export function initDefaultSlashCommands() { eventSource.on(event_types.CHAT_CHANGED, processChatSlashCommands); setupConnectAPIMap(); + const promptInjectionsAllowed = getActiveIosPolicyCapabilities()?.scripting?.prompt_injections !== false; async function enableInstructCallback() { $('#instruct_enabled').prop('checked', true).trigger('input').trigger('change'); @@ -2496,99 +2498,101 @@ export function initDefaultSlashCommands() { ], helpString: t`Trims the text to the end of the last full sentence.`, })); - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'inject', - returns: t`injection ID`, - callback: injectCallback, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'id', - description: t`injection ID`, - typeList: [ARGUMENT_TYPE.STRING], - isRequired: false, - enumProvider: commonEnumProviders.injects, - }), - new SlashCommandNamedArgument( - 'position', t`injection position`, [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat', 'none'], - ), - new SlashCommandNamedArgument( - 'depth', t`injection depth`, [ARGUMENT_TYPE.NUMBER], false, false, '4', - ), - new SlashCommandNamedArgument( - 'scan', t`include injection content into World Info scans`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'role', - description: t`role for in-chat injections`, - typeList: [ARGUMENT_TYPE.STRING], - isRequired: false, - enumList: [ - new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), - new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), - new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), - ], - }), - new SlashCommandNamedArgument( - 'ephemeral', t`remove injection after generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', - ), - SlashCommandNamedArgument.fromProps({ - name: 'filter', - description: t`if a filter is defined, an injection will only be performed if the closure returns true`, - typeList: [ARGUMENT_TYPE.CLOSURE], - isRequired: false, - acceptsMultiple: false, - }), - ], - unnamedArgumentList: [ - new SlashCommandArgument( - t`text`, [ARGUMENT_TYPE.STRING], false, - ), - ], - helpString: t`Injects a text into the LLM prompt for the current chat. Requires a unique injection ID (will be auto-generated if not provided). Positions: "before" main prompt, "after" main prompt, in-"chat", hidden with "none" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false). Hidden injects in "none" position are not inserted into the prompt but can be used for triggering WI entries. Returns the injection ID.`, - })); - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'listinjects', - callback: listInjectsCallback, - helpString: t`Lists all script injections for the current chat. Displays injects in a popup by default. Use the return argument to change the return type.`, - returns: t`Optionally the JSON object of script injections`, - namedArgumentList: [ - SlashCommandNamedArgument.fromProps({ - name: 'return', - description: t`The way how you want the return value to be provided`, - typeList: [ARGUMENT_TYPE.STRING], - defaultValue: 'popup-html', - enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }), - forceEnum: true, - }), - // TODO remove some day - SlashCommandNamedArgument.fromProps({ - name: 'format', - description: t`!!! DEPRECATED - use "return" instead !!! output format`, - typeList: [ARGUMENT_TYPE.STRING], - isRequired: true, - forceEnum: true, - enumList: [ - new SlashCommandEnumValue('popup', t`Show injects in a popup.`, enumTypes.enum, enumIcons.default), - new SlashCommandEnumValue('chat', t`Post a system message to the chat.`, enumTypes.enum, enumIcons.default), - new SlashCommandEnumValue('none', t`Just return the injects as a JSON object.`, enumTypes.enum, enumIcons.default), - ], - }), - ], - })); - SlashCommandParser.addCommandObject(SlashCommand.fromProps({ - name: 'flushinject', - aliases: ['flushinjects'], - unnamedArgumentList: [ - SlashCommandArgument.fromProps({ - description: t`injection ID or a variable name pointing to ID`, - typeList: [ARGUMENT_TYPE.STRING], - defaultValue: '', - enumProvider: commonEnumProviders.injects, - }), - ], - callback: flushInjectsCallback, - helpString: t`Removes a script injection for the current chat. If no ID is provided, removes all script injections.`, - })); + if (promptInjectionsAllowed) { + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'inject', + returns: t`injection ID`, + callback: injectCallback, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'id', + description: t`injection ID`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + enumProvider: commonEnumProviders.injects, + }), + new SlashCommandNamedArgument( + 'position', t`injection position`, [ARGUMENT_TYPE.STRING], false, false, 'after', ['before', 'after', 'chat', 'none'], + ), + new SlashCommandNamedArgument( + 'depth', t`injection depth`, [ARGUMENT_TYPE.NUMBER], false, false, '4', + ), + new SlashCommandNamedArgument( + 'scan', t`include injection content into World Info scans`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'role', + description: t`role for in-chat injections`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: false, + enumList: [ + new SlashCommandEnumValue('system', null, enumTypes.enum, enumIcons.system), + new SlashCommandEnumValue('assistant', null, enumTypes.enum, enumIcons.assistant), + new SlashCommandEnumValue('user', null, enumTypes.enum, enumIcons.user), + ], + }), + new SlashCommandNamedArgument( + 'ephemeral', t`remove injection after generation`, [ARGUMENT_TYPE.BOOLEAN], false, false, 'false', + ), + SlashCommandNamedArgument.fromProps({ + name: 'filter', + description: t`if a filter is defined, an injection will only be performed if the closure returns true`, + typeList: [ARGUMENT_TYPE.CLOSURE], + isRequired: false, + acceptsMultiple: false, + }), + ], + unnamedArgumentList: [ + new SlashCommandArgument( + t`text`, [ARGUMENT_TYPE.STRING], false, + ), + ], + helpString: t`Injects a text into the LLM prompt for the current chat. Requires a unique injection ID (will be auto-generated if not provided). Positions: "before" main prompt, "after" main prompt, in-"chat", hidden with "none" (default: after). Depth: injection depth for the prompt (default: 4). Role: role for in-chat injections (default: system). Scan: include injection content into World Info scans (default: false). Hidden injects in "none" position are not inserted into the prompt but can be used for triggering WI entries. Returns the injection ID.`, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'listinjects', + callback: listInjectsCallback, + helpString: t`Lists all script injections for the current chat. Displays injects in a popup by default. Use the return argument to change the return type.`, + returns: t`Optionally the JSON object of script injections`, + namedArgumentList: [ + SlashCommandNamedArgument.fromProps({ + name: 'return', + description: t`The way how you want the return value to be provided`, + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: 'popup-html', + enumList: slashCommandReturnHelper.enumList({ allowPipe: false, allowObject: true, allowChat: true, allowPopup: true, allowTextVersion: false }), + forceEnum: true, + }), + // TODO remove some day + SlashCommandNamedArgument.fromProps({ + name: 'format', + description: t`!!! DEPRECATED - use "return" instead !!! output format`, + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + forceEnum: true, + enumList: [ + new SlashCommandEnumValue('popup', t`Show injects in a popup.`, enumTypes.enum, enumIcons.default), + new SlashCommandEnumValue('chat', t`Post a system message to the chat.`, enumTypes.enum, enumIcons.default), + new SlashCommandEnumValue('none', t`Just return the injects as a JSON object.`, enumTypes.enum, enumIcons.default), + ], + }), + ], + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'flushinject', + aliases: ['flushinjects'], + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: t`injection ID or a variable name pointing to ID`, + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '', + enumProvider: commonEnumProviders.injects, + }), + ], + callback: flushInjectsCallback, + helpString: t`Removes a script injection for the current chat. If no ID is provided, removes all script injections.`, + })); + } SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'tokens', callback: (_, text) => { diff --git a/src/scripts/tauri/setting/setting-panel/settings-popup.js b/src/scripts/tauri/setting/setting-panel/settings-popup.js index 59826c7b..8e02590c 100644 --- a/src/scripts/tauri/setting/setting-panel/settings-popup.js +++ b/src/scripts/tauri/setting/setting-panel/settings-popup.js @@ -22,6 +22,7 @@ import { setChatHistoryBootstrapModeName, } from '../../../../tauri/main/services/chat-history/chat-history-mode-state.js'; import { runOrPopup } from './popup-utils.js'; +import { getActiveIosPolicyCapabilities } from '../../../tauritavern/ios-policy.js'; function isWindowsPlatform() { return typeof navigator !== 'undefined' @@ -30,6 +31,9 @@ function isWindowsPlatform() { export async function openTauriTavernSettingsPopup() { const settings = await getTauriTavernSettings(); + const iosCaps = getActiveIosPolicyCapabilities(); + const requestProxyAllowed = iosCaps?.network?.request_proxy !== false; + const lanSyncAllowed = iosCaps?.sync?.lan !== false; const supportsCloseToTrayOnClose = isWindowsPlatform() && !isMobile(); // Data directory selection is a desktop-only feature. Do not gate this on Bowser's `isMobile()`, // because iPadOS may present a desktop-like user agent (e.g. platform "MacIntel"). @@ -157,7 +161,7 @@ export async function openTauriTavernSettingsPopup() { Requires reload to apply.
-
+
System
@@ -321,7 +325,7 @@ export async function openTauriTavernSettingsPopup() {
-
+
Sync
@@ -457,6 +461,16 @@ export async function openTauriTavernSettingsPopup() { if (!(requestProxyDetails instanceof HTMLDetailsElement)) { throw new Error('TauriTavern settings: request proxy details not found'); } + if (!requestProxyAllowed) { + requestProxyDetails.hidden = true; + } + if (!requestProxyAllowed && !supportsDataRootSelection) { + const systemPanel = root.querySelector('#tt-system-panel'); + if (!(systemPanel instanceof HTMLElement)) { + throw new Error('TauriTavern settings: system panel not found'); + } + systemPanel.hidden = true; + } const requestProxySummaryHint = root.querySelector('#tt-request-proxy-summary-hint'); if (!(requestProxySummaryHint instanceof HTMLElement)) { @@ -712,10 +726,18 @@ export async function openTauriTavernSettingsPopup() { if (!(openSyncButton instanceof HTMLElement)) { throw new Error('TauriTavern settings: sync button not found'); } - openSyncButton.addEventListener('click', () => runOrPopup(async () => { - const { openSyncPopup } = await import('./sync-popup.js'); - await openSyncPopup(); - })); + if (!lanSyncAllowed) { + const syncPanel = root.querySelector('#tt-sync-panel'); + if (!(syncPanel instanceof HTMLElement)) { + throw new Error('TauriTavern settings: sync panel not found'); + } + syncPanel.hidden = true; + } else { + openSyncButton.addEventListener('click', () => runOrPopup(async () => { + const { openSyncPopup } = await import('./sync-popup.js'); + await openSyncPopup(); + })); + } const panelRuntimeHelp = root.querySelector('#tt-help-panel-runtime'); if (!(panelRuntimeHelp instanceof HTMLElement)) { diff --git a/src/scripts/tauritavern/ios-policy-ui.js b/src/scripts/tauritavern/ios-policy-ui.js new file mode 100644 index 00000000..1127aa7a --- /dev/null +++ b/src/scripts/tauritavern/ios-policy-ui.js @@ -0,0 +1,210 @@ +import { allowlistSettingAllows, getActiveIosPolicyActivationReport } from './ios-policy.js'; + +const STYLE_ID = 'tt-ios-policy-ui-projection'; + +function ensureStyleElement() { + let style = document.getElementById(STYLE_ID); + if (style instanceof HTMLStyleElement) { + return style; + } + + style = document.createElement('style'); + style.id = STYLE_ID; + document.head.appendChild(style); + return style; +} + +function requireElement(selector, context = document) { + const element = context.querySelector(selector); + if (!element) { + throw new Error(`[TauriTavern][iOSPolicy] Expected element not found: ${selector}`); + } + return element; +} + +function hideElement(element) { + if (!(element instanceof HTMLElement)) { + throw new Error('[TauriTavern][iOSPolicy] hideElement: element must be an HTMLElement'); + } + + element.hidden = true; + element.setAttribute('data-tt-ios-policy-hidden', '1'); +} + +function normalizeChatCompletionSourceKey(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized.startsWith('custom_')) { + return 'custom'; + } + return normalized; +} + +function filterChatCompletionSourceOptions({ allowlist, endpointOverridesAllowed }) { + const select = requireElement('#chat_completion_source'); + if (!(select instanceof HTMLSelectElement)) { + throw new Error('[TauriTavern][iOSPolicy] #chat_completion_source must be a