feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish

Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
Rocky
2026-08-13 01:00:51 +08:00
parent fd6e0d3e7e
commit 9f308fadd2
202 changed files with 10897 additions and 5962 deletions
+4
View File
@@ -32,6 +32,10 @@ What you expected to happen.
Paste the relevant Console.app output filtered to `OSGKeyboard`. Wrap in triple backticks.
Before uploading, remove DEBUG transcript/prompt/clipboard content, API keys,
authorization headers, request/response bodies, and any other credentials or
personal data. Do not attach unreviewed raw DEBUG logs.
```
<logs here>
```
+79 -66
View File
@@ -2,93 +2,106 @@ name: CI
on:
push:
branches: [0.1, 0.2, develop]
branches: [main]
pull_request:
branches: [0.1, 0.2, develop]
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# =========================================================
# SwiftLint (light config — fails only on severe issues)
# =========================================================
validate:
name: Validate manifests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Validate test suite manifest
run: ./Scripts/run-tests.sh validate
- name: Check sensitive logs
run: |
python3 Scripts/check_sensitive_logs.py --self-test
python3 Scripts/check_sensitive_logs.py
lint:
name: SwiftLint
# macos-14 runner ships Xcode 16.x (default 16.4 in 2026). We pin to
# 16.4 instead of hard-coding a path because the runner image is
# refreshed regularly and `/Applications/Xcode_16.0.app` is no
# longer pre-installed — that's the bug this PR fixes.
runs-on: macos-14
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app
- name: Install SwiftLint
run: brew install swiftlint
- name: Run SwiftLint
# Run twice: --quiet to fail on errors only, --strict to fail
# on warnings too. The current .swiftlint.yml is intentionally
# permissive (line_length, file_length, function_body_length,
# type_body_length are all disabled) so this should pass on
# the audit/appstore-prep branch.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Verify toolchain
run: |
swiftlint lint --quiet
swiftlint lint --quiet --strict
set -euo pipefail
sudo xcode-select -s /Applications/Xcode_26.6.app
xcodebuild -version
test "$(xcodebuild -version | awk 'NR == 1 { print $2 }')" = "26.6"
test "$(xcrun --sdk iphonesimulator --show-sdk-version | cut -d. -f1)" = "26"
command -v swiftlint >/dev/null || brew install swiftlint
test "$(swiftlint version)" = "0.65.0"
- name: Run SwiftLint
run: swiftlint lint --quiet --strict
# =========================================================
# Build the main app + keyboard extension
# =========================================================
build:
name: Build (${{ matrix.destination }})
needs: lint
runs-on: macos-14
strategy:
fail-fast: false
matrix:
destination:
- generic/platform=iOS Simulator
ios:
name: iOS / Extension
needs: [validate, lint]
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app
- name: Install XcodeGen
run: brew install xcodegen
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Verify toolchain
run: |
set -euo pipefail
sudo xcode-select -s /Applications/Xcode_26.6.app
xcodebuild -version
test "$(xcodebuild -version | awk 'NR == 1 { print $2 }')" = "26.6"
test "$(xcrun --sdk iphonesimulator --show-sdk-version | cut -d. -f1)" = "26"
- name: Install pinned XcodeGen
run: ./Scripts/install-xcodegen-ci.sh
- name: Generate project
run: ./Scripts/generate-xcodeproj.sh
- name: Build
- name: Run PR test preset
run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
- name: Build Release
run: |
set -o pipefail
set -euo pipefail
xcodebuild \
-project OSGKeyboard.xcodeproj \
-scheme OSGKeyboard \
-destination "${{ matrix.destination }}" \
-configuration Debug \
build \
| xcpretty
-destination 'generic/platform=iOS Simulator' \
-configuration Release \
-onlyUsePackageVersionsFromResolvedFile \
CODE_SIGNING_ALLOWED=NO \
build
# =========================================================
# Unit tests
# =========================================================
test:
name: Unit tests
needs: lint
runs-on: macos-14
mac:
name: macOS
needs: [validate, lint]
runs-on: macos-26
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.4
run: sudo xcode-select -s /Applications/Xcode_16.4.app
- name: Install XcodeGen
run: brew install xcodegen
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Verify toolchain
run: |
set -euo pipefail
sudo xcode-select -s /Applications/Xcode_26.6.app
xcodebuild -version
test "$(xcodebuild -version | awk 'NR == 1 { print $2 }')" = "26.6"
test "$(uname -m)" = "arm64"
- name: Install pinned XcodeGen
run: ./Scripts/install-xcodegen-ci.sh
- name: Generate project
run: ./Scripts/generate-xcodeproj.sh
- name: Validate test suite manifest
run: ./Scripts/run-tests.sh validate
- name: Run tests (pr preset)
# pr = critical-path groups including OSGKeyboardExtTests.
# See Tests/suite-manifest.json and docs/TESTING.md.
- name: Run macOS tests
run: SKIP_GENERATE=1 ./Scripts/run-tests.sh mac
- name: Build Release
run: |
set -o pipefail
SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
set -euo pipefail
xcodebuild \
-project OSGKeyboard.xcodeproj \
-scheme OSGKeyboardMac \
-destination 'platform=macOS' \
-configuration Release \
-onlyUsePackageVersionsFromResolvedFile \
CODE_SIGNING_ALLOWED=NO \
build
+6
View File
@@ -24,7 +24,13 @@ included:
- OSGKeyboard
- OSGKeyboardExt
- OSGKeyboardShared
- OSGKeyboardHostSupport
- OSGKeyboardMac
- OSGKeyboardTests
- OSGKeyboardExtTests
- OSGKeyboardMacTests
- OSGKeyboardUITests
- Tests
excluded:
- build
+24 -22
View File
@@ -1,27 +1,33 @@
# AGENTS.md
## License boundary
OSGKeyboard is **source available, not open source**. `LICENSE` permits personal,
non-commercial local use and forbids unauthorized redistribution or public derivative
versions. Do not describe the project as MIT-licensed, open source, or freely forkable.
## Versioning and releases
OSGKeyboard uses **Conventional Commits** as the single source of truth for version bumps
and `CHANGELOG.md` entries. Agents must follow this section whenever cutting a release or
writing commit messages that will ship to users.
### Version format (0.x stage)
### Version format
While `MARKETING_VERSION` is `0.x.y`, treat the project as **pre-1.0**:
The current source-of-truth version is **1.7.0 (build 65)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
| Marketing version | `project.yml``MARKETING_VERSION` | `0.MINOR.PATCH` (SemVer) |
| Marketing version | `project.yml``MARKETING_VERSION` | `MAJOR.MINOR.PATCH` (SemVer) |
| Build number | `project.yml``CURRENT_PROJECT_VERSION` | Monotonic integer; **+1 on every release cut**, never decrease |
**Bump rules** (evaluate all commits since the last tagged/released version; take the **highest** bump):
| Commit prefix | Version bump | Example |
|---------------|--------------|---------|
| `feat:` | **MINOR** + reset PATCH → `0` | `0.3.6``0.4.0` |
| `fix:`, `perf:` (user-visible) | **PATCH** | `0.3.6``0.3.7` |
| `feat!:` or footer `BREAKING CHANGE:` | **MINOR** (pre-1.0; reserve `1.0.0` for a deliberate GA) | `0.3.6``0.4.0` |
| `feat:` | **MINOR** + reset PATCH → `0` | `1.7.0``1.8.0` |
| `fix:`, `perf:` (user-visible) | **PATCH** | `1.7.0``1.7.1` |
| `feat!:` or footer `BREAKING CHANGE:` | **MAJOR** | `1.7.0` `2.0.0` |
| `refactor:`, `style:`, `docs:`, `test:`, `chore:`, `ci:` | **no bump by itself** | group with user-facing commits or skip release |
Pragmatic overrides (experienced-maintainer judgment, still objective):
@@ -71,13 +77,13 @@ chore(lexicon): add offline SFCustomLanguageModelData export scripts
**Example entry:**
```markdown
## [0.4.0] - 2026-07-06
## [1.8.0] - 2026-09-01
### Added
- **Cursor navigation**: drag pad on the keyboard for precise caret movement. / **光标导航**:键盘拖动手势区,精确移动光标。
### Fixed
- **API key handling**: move DeepSeek key into gitignored local file. / **API 密钥**:将 DeepSeek 密钥移至 gitignore 的本地文件
- **API key handling**: keep user-owned provider keys in Keychain. / **API 密钥**:将用户自备的服务商密钥保存在 Keychain
```
### Release checklist (agent)
@@ -87,7 +93,7 @@ When the user asks to release or bump version:
1. `git log` from last release tag/commit → classify commits → pick bump level.
2. Update `CHANGELOG.md` (`[Unreleased]``[X.Y.Z] - date`, bilingual bullets).
3. Update `project.yml`:
- `MARKETING_VERSION` → new `0.x.y`
- `MARKETING_VERSION` → new SemVer version
- `CURRENT_PROJECT_VERSION` → previous build **+ 1**
4. Commit: `chore(release): bump version to X.Y.Z (build N)` — or include in the release PR.
5. Do **not** bump version for work that stays on a feature branch until it merges to `main`.
@@ -106,20 +112,22 @@ When the user asks to release or bump version:
## Cursor Cloud specific instructions
### Platform reality: this is an iOS-only project on a Linux VM
### Platform reality: this is an Apple-platform project on a Linux VM
OSGKeyboard is a native **iOS 18+** app (main app + custom keyboard extension + shared
framework, all Swift 6 / SwiftUI). The Cursor Cloud VM is **Linux x86_64**. iOS development
is fundamentally macOS-only, so the following **cannot run in this environment**:
OSGKeyboard contains a native **iOS/iPadOS 26+** app and keyboard extension plus a
native **macOS 15+** menu-bar app (Swift 6 / SwiftUI). The iOS host links
`OSGKeyboardShared` and the host-only `OSGKeyboardHostSupport`; the Mac target reuses
shared/host-support sources and links MLX Audio for Qwen3 streaming ASR. The Cursor Cloud VM
is **Linux x86_64**. Apple-platform development is macOS-only, so the following **cannot run
in this environment**:
- **Build** — needs `xcodebuild` + the iOS SDK (Xcode, macOS only).
- **Run** — needs the iOS Simulator or a physical iPhone (macOS only).
- **Tests** — `OSGKeyboardTests` / `OSGKeyboardExtTests` run via `xcodebuild test` against the
iOS Simulator (macOS only).
- **Tests** — iOS/extension tests require an iOS Simulator; `OSGKeyboardMacTests` requires macOS.
Nearly every source file imports iOS-only frameworks (`SwiftUI`, `UIKit`, `AVFoundation`,
`Speech`, `Combine`), so there is no meaningful subset that compiles with Swift-for-Linux.
Do **not** attempt to build/run/test on the Linux VM — escalate to a macOS host with Xcode 16+
Do **not** attempt to build/run/test on the Linux VM — escalate to a macOS host with Xcode 26+
(see `README.md` / `CONTRIBUTING.md` for the `xcodegen generate` + `xcodebuild` flow).
### What *does* work on Linux: SwiftLint
@@ -144,9 +152,3 @@ Caveats:
The `.xcodeproj` is **gitignored**; `project.yml` (XcodeGen) is the source of truth. On macOS run
`xcodegen generate` before any `xcodebuild`. XcodeGen is macOS-oriented and is not installed on
the Linux VM.
### CI note
`.github/workflows/ci.yml` runs on `macos-14` and currently fails at the `xcode-select -s
/Applications/Xcode_16.0.app` step because that Xcode version is absent from GitHub's current
`macos-14` image — this is a CI runner-image issue, unrelated to the code or this Linux setup.
-313
View File
@@ -1,313 +0,0 @@
# App Store 上架前审计报告 — v0.1.2
**项目**: hkgood/OSGKeyboard
**基线**: main @ `642c97c`
**审计日期**: 2026-06-20
**审计员**: 中书省 (zhongshu subagent)
**目标**: 满足 Apple App Store Review Guidelines + iOS 26 + Custom Keyboard Extension 审核要点
---
## TL;DR
仓库代码质量整体良好(iOS 26 only / Swift 6 / 零依赖 / 隐私 manifest / onboarding 全套 / Flow 模型完整)。**阻塞上架的 P0 项集中在 CI 运维和 App Store Connect 元数据/资源**,**没有发现代码层面的功能缺陷或安全漏洞**。修完 6 项 P0 即可提交;P1/P2 建议在后续小版本里迭代。
### 优先级分布
| 优先级 | 数量 | 状态 |
|--------|------|------|
| **P0**(必须修,阻塞上架) | 6 | 本审计已全部落地 |
| **P1**(强烈建议) | 5 | 留给 v0.1.3 后续 |
| **P2**(可选优化) | 4 | 长期 roadmap |
---
## P0 — 阻塞上架
### P0-1. CI workflow 引用已不存在的 Xcode 版本 ✅ 已修
**现状**: `.github/workflows/ci.yml` 三个 joblint / build / test)都写死 `/Applications/Xcode_16.0.app`,但 GitHub Actions `macos-14` runner 默认已无此版本(Xcode 16.0 是 macos-14 runner 早期预装版本,已被替换为 Xcode 16.x 最新 stable)。
**影响**:
- lint job: `sudo xcode-select -s /Applications/Xcode_16.0.app` 直接失败
- build job: matrix 写死 `xcode: "16.0"`,同样失败
- test job: 同样失败
-**CI 一直红**,合并任何 PR 都看不出真错
**修复**:
- 升级 `macos-14``macos-15`GitHub-hosted runner 自带 Xcode 26.x
- 删除 matrix 中 `xcode: "16.0"` 字段,改为 `macos-15` 自带的 Xcode
- 改用 `sudo xcode-select -s /Applications/Xcode_16.4.app`macos-15 预装的版本号;26 系列需要 macos-26 runner 上线后才稳定;先锁 16.4 是 CI 可复现的稳妥选择,并增加 `select-xcode` 步骤用 `xcversion``agvtool` 探测)
> **实际修复策略**: macos-14 保留但改用 `maxim-lobanov/setup-xcode@v1` action 自动选最高稳定版,或者升级到 `macos-15`(更直接,已实施)。
**落地 commit**: 见 commit `chore(ci): pin Xcode 16.4 + macos-15 runner`
---
### P0-2. release 配置残留 `print()` 与 `NSLog` ✅ 已评估并保留 #if DEBUG 包裹
**摸底结果**(与太子原话略有差异 — 已核实):
| 位置 | 上下文 | 是否泄漏到 release |
|---|---|---|
| `OSGKeyboard/Services/FlowSessionManager.swift:465` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Models/ProviderConfig.swift:49` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Services/LLMClient.swift:103` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Services/LiveDictationController.swift:503` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Services/Keychain.swift:70` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Services/ASRService.swift:236` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardExt/KeyboardViewController.swift:697` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardExt/Services/AppGroupPersistor.swift:50` | `#if DEBUG ... print ... #endif` | ❌ 不泄漏 |
| `OSGKeyboardShared/Constants/AppGroup.swift:60` | `NSLog(...)` 写在 release fallback 路径 | ⚠️ **真泄漏** |
**结论**: 8 个 `print()` 全部由 `#if DEBUG` 包裹,**Release 构建不会编译这些语句**。唯一 release-可见的是 `AppGroup.swift:60``NSLog`
**App Store 审核对 `print` 的态度**: 严格说不查日志语句本身,App Review 只关心功能/隐私/UI。但苹果审核员在控制台中看到的 NSLog 输出,会被列为"non-issues"或"建议优化"**不会因此被拒**。但我们仍做 P1 优化(替换为 `os.Logger`,可在 release 下也输出但经 `OSLog` 隐私控制)。
**真 P0 问题**: `AppGroup.swift:60``NSLog` 会出现在 release 设备日志里、且文案暴露内部细节。已建议:换成 `os.Logger`,文案更克制。
---
### P0-3. Legacy 路径评估 ✅ 实际只 1 个文件可删
太子原话: "删除 `LiveDictationController / DictationBridge / AudioCaptureService`"
**核实结果**grep 全仓):
| 文件 | 引用者 | 是否真 dead | 结论 |
|---|---|---|---|
| `OSGKeyboardShared/Services/AudioCaptureService.swift` | 仅 .swift 注释、.swift 源本身 | **✅ 真 dead** | **可删** |
| `OSGKeyboardShared/Services/DictationBridge.swift` | `KeyboardViewController.swift:594,601,639`**实际消费 pending transcript** | ❌ 在用 | **不可删** |
| `OSGKeyboardShared/Services/LiveDictationController.swift` | `PreviewASRController.swift`typealias)、`DictationCaptureView.swift``KeyboardPreviewSheet.swift``PreviewASRControllerStateTests.swift` | ❌ 在用 | **不可删** |
**结论**: "Legacy" 是相对概念。DictationBridge 和 LiveDictationController 实际上仍然在用,只是角色从"主路径"变成了"调试预览 + 一次性 handoff"。
**修复**:
- ✅ 删除 `AudioCaptureService.swift`(真正无人调用的死代码)
- ❌ 保留 `DictationBridge.swift`KeyboardViewController 仍消费)
- ❌ 保留 `LiveDictationController.swift`(作为 PreviewASRController 仍在用)
- 文档化:在 `LiveDictationController.swift` 文件头补一段说明"虽然叫 LiveDictation 但已不是主路径,主路径是 FlowSessionManager/FlowContinuousCapture;本类服务于键盘内嵌预览场景"。
**落地 commit**: 见 commit `chore: remove dead AudioCaptureService and clarify LiveDictationController scope`
---
### P0-4. `ITSAppUsesNonExemptEncryption=NO` 缺失 ✅ 已加
**现状**: `OSGKeyboard/Info.plist` 没有 `ITSAppUsesNonExemptEncryption` 键。
**影响**: App Store 上传时弹出"App Encryption"问卷,需要每次手填一遍"使用 HTTPS,不含非豁免加密"。最简方案是显式声明 `NO`,直接跳过问卷。
**修复**: 在主 App `Info.plist``<key>ITSAppUsesNonExemptEncryption</key><false/>`。键盘扩展不需要此键(不是 app)。
**落地 commit**: 见 commit `chore: declare ITSAppUsesNonExemptEncryption=NO for App Store upload`
---
### P0-5. App Store 截图缺失 ✅ 已生成占位 + 元数据
**现状**:
- `docs/assets/` 只有 `app-icon.png` (1024×1024) — 不是 App Store 截图
- 缺 6.7" (iPhone 17 Pro Max) / 6.1" (iPhone 17 Pro) / 5.5" (iPhone 8 Plus) 三套
- 缺 App Store Connect 元数据(description、promo text、keywords、release notes、support URL、marketing URL、privacy policy URL
**Apple 实际要求(2026**:
- **6.7"** (1290×2796) — 必需
- **6.1"** (1179×2556) — 必需(iPhone 17 Pro
- 5.5" 已被苹果官方文档降级为"可选"iPhone 8 Plus 等已停产机型)
- iPad 截图 — **现已必需**:项目自 TARGETED_DEVICE_FAMILY "1,2" 起支持 iPadApp Store Connect 要求提供 13″ iPad Pro 截图套组(本条为后续更新覆盖原「iPhone only 无需提供」的结论)
- 每套至少 3 张、最多 10 张
**本审计交付**:
- `docs/screenshots/` 目录
- `docs/screenshots/README.md` — 截图规范说明
- `docs/APPSTORE_METADATA.md` — 完整 App Store Connect 文案(含 description、promo text、keywords、release notes、whats new、support URL、marketing URL、privacy policy URL
- 6.7" / 6.1" 各 5 张的占位 — **实际占位 PNG(用脚本生成纯色 + 文字标题的 1290×2796 / 1179×2556 PNG**。真实 UI 截图需要人工在 Xcode Simulator 跑出再替换。
**落地 commit**: 见 commit `docs: App Store screenshots placeholder + APPSTORE_METADATA.md`
> ⚠️ **真人事项**: 真实截图(带 UI 实际效果)需要太子或皇上在 Xcode Simulator (iPhone 17 Pro / Pro Max) 跑出 5+5 张并替换。已在 APPSTORE_METADATA.md 末尾给出截图拍摄脚本(xcrun simctl io booted screenshot ...)。
---
### P0-6. `v0.1.2` git tag 缺失 ✅ 已加
**现状**: `git tag -l` 空。
**影响**: App Store Connect 上传二进制时**不强制要 tag**,但 App Store Connect "Version" 字段对 tag 命名有强提示作用,且 CHANGELOG 里有 `## [0.1.2] - In Progress`,没 tag 看着像没发布。
**修复**: 创建 annotated tag `v0.1.2`,信息参考 CHANGELOG 的 0.1.2 段。
**落地 commit**: 见 commit `chore: tag v0.1.2 (App Store submission prep)`
> ⚠️ 推送 tag = `git push origin v0.1.2`,需要 token。
---
## P1 — 强烈建议(v0.1.3 候选)
### P1-1. SwiftLint strict 模式未启用
**摸底**: 当前 `.swiftlint.yml``disabled_rules` 去掉 `line_length` / `file_length` / `function_body_length` / `type_body_length` / `cyclomatic_complexity` 等大量 rule。**严格模式 (`--strict`) 把 warnings 升级为 errors**,如果启用会爆 134 warnings。
**未启用原因**: CI 第 47 行 `swiftlint lint --quiet --strict` 在 workflow 里**实际跑**(不是关掉),但因为没有 strict 模式触发条件所以通过。复现:在 strict 模式下,134 warnings → errors。
**建议修复**:
- 保持 `line_length` / `file_length` 禁用(不然误伤大)
- 启用 `sorted_imports` / `explicit_init` / `empty_count` / `first_where` 等已 opt_in 但实际报错的 rule
- 给 5 个超 400 行的文件(`SettingsView.swift 427行``OnboardingView.swift 581行``FlowSessionManager.swift 468行``KeyboardViewController.swift 700行``KeyboardRootView.swift 520行``LiveDictationController.swift 506行`)做拆分 task
- 短期:CI 不改 strict 模式,但开启 strict 后通过的 patch 进 v0.1.3
### P1-2. 测试覆盖率低
**现状**: 7 个测试文件 / 873 行 / 7 个 test class,未覆盖:
- `FlowSessionManager`(核心)— 0 测试
- `FlowContinuousCapture`(核心)— 0 测试
- `PolishingService` — 0 测试
- `KeyboardViewController` — 0 测试
- `ASRService` (SpeechAnalyzer 路径) — 仅 1 个 state 测试
**建议**: 至少补 1) `FlowSessionManager` start/stop 状态机 2) `PolishingService` 三种模式(off/transcribe/polish3) `KeyboardViewController` 的 partial transcript 注入逻辑。
### P1-3. README 截图缺失
**现状**: `README.md` 没有截图段,全是 emoji + 链接。
**建议**: 加 `docs/screenshots/` 实际 UI 截图,README 顶部加 `<img src="docs/screenshots/main.png" width="320">` 段。
### P1-4. 键盘 UI 引导动图缺失
**现状**: 首次启用键盘需要 3 步(设置→键盘→添加→允许 Full Access),onboarding 是文字步骤,没动图。
**建议**: 用 QuickTime 录 30 秒 GIF,嵌到 `OSGKeyboard/OnboardingView.swift` 第 3 步下方。
### P1-5. 用 `os.Logger` 替换 `print` / `NSLog`
**现状**: 见 P0-2。`AppGroup.swift:60``NSLog` 是唯一 release 可见日志。
**建议**: 引入 `OSGLog` enum(封装 `os.Logger`),全局替换。这样 release 下仍能在 Console.app / `log stream` 看到带 subsystem 的日志,但被 `OSLog` 隐私协议控制(不会把 API key 等敏感字段写出去)。
---
## P2 — 可选长期
### P2-1. Apple Foundation Models 本地润色
iOS 26 提供 `FoundationModels` 框架(设备端 LLM),可用作"polish 模式"的可选 backend,不消耗用户 API key、不外发。
**预估工作量**: 2-3 天(适配接口、prompt 工程、UI toggle、隐私政策更新)。
### P2-2. 新增 Anthropic / Gemini provider
当前 6 个 LLM providerOpenAI / DeepSeek / Qwen / Apple / Custom / Moonshot / Zhipu(实际 7 个)。Anthropic Claude / Google Gemini 都还没原生 provider。Claude 用 Anthropic Messages API(非 OpenAI 兼容),Gemini 用 Gemini API(也非 OpenAI 兼容)。
**预估工作量**: 4-5 天(含 token 估算、流式响应、错误处理、UI)。
### P2-3. 性能 profiling
键盘 extension 内存预算 60 MB。当前 `FlowContinuousCapture` + `ASRService` + 音频 buffer 三者驻留。建议跑 Instruments → Allocations / Time Profiler,验证 peak memory < 40 MB。
### P2-4. App Store 内置评分请求
`SKStoreReviewController.requestReview(in:)` 在 Settings 加 "Rate OSGKeyboard" 按钮,不打断主流程。
---
## 跨领域观察(不在 P0/P1/P2 单项里的杂项)
1. **键盘扩展无 `CFBundleURLTypes`(太子列为 P0-12****核实后非问题**。键盘扩展**不能**通过 URL scheme 启动 host apphost app 的 `CFBundleURLTypes` 是为了从 Settings/外链唤起 App,与扩展无关。**已确认无需修**。
2. **App Group fallback 行为不一致(太子列为 P0-13)****实际是设计意图**`AppGroup.swift:51-67` 注释明确说明:DEBUG `fatalError` 是为了"硬崩防止 desync 漏掉 bug"release `NSLog + 软 fallback` 是为了"App 不至于直接挂掉"。审核员不会因为 release 软 fallback 而拒。**已确认无需改 P0,归 P1-5 一起处理**。
3. **`FlowContinuousCapture` 依赖 `.playAndRecord` audio session** — **实际已处理**`OSGKeyboardExt` 用的是 host App 的 `FlowSessionManager`(在主 App target 而非 ext),不冲突。键盘扩展内 AudioCaptureService 是死代码(P0-3 已删)。**已确认无问题**。
4. **`NSSupportsLiveTextFrequentUpdates`(太子列为 P0-11** — **核实**。Apple 在 iOS 17 引入 `NSSupportsLiveText` / iOS 18 加 `NSSupportsLiveTextFrequentUpdates`,但这是**宿主 app 用于处理 Live Text 数据流**(相机/照片),与 custom keyboard extension 无关。键盘扩展加这个 key 不会被苹果拒,但也没用。**已确认无需加**。
5. **ASRService 仍引用 iOS 26 之前的 SFSpeechRecognizer fallback(太子列为 P0-14****核实后非问题**。ASRService 的 ASR 后端**只有 SpeechAnalyzer**iOS 26 only),但 `SettingsView``AppPermissions``SFSpeechRecognizer.supportedLocales()` / `authorizationStatus()` 等**元数据 API**(这些 API 在 iOS 26 仍存在且无 deprecation),完全合法。**已确认无需修**。
6. **测试运行 destination 是 iPhone 17CI `ci.yml:79`****核实**。Apple 2026 年 Simulator 列表中 iPhone 17 是 stable;本机是 iPhone 17 Pro。CI 用 `iPhone 17`(无 Pro),是 Apple 公开的标准 simulator 名,应该能跑。**已确认 OK**。
7. **Privacy manifest 完整度**`OSGKeyboard/PrivacyInfo.xcprivacy``OSGKeyboardExt/PrivacyInfo.xcprivacy` 都声明了 `NSPrivacyAccessedAPICategoryUserDefaults` (CA92.1) 原因。**没有用磁盘 API / 系统启动时间 API**(除了 `containerURL`,它本身不需要声明 reason),所以不必加其他 API reason。**已确认合规**。
8. **AppIcon.appiconset 1024x1024 PNG** — ✅ 已确认 `Group 24.png` 1024×1024App Store 上传最低要求。
9. **CHANGELOG 0.1.0 已有**`## [0.1.0] - 2026-06-17`v0.1.1 缺失(CHANGELOG 提到"v0.1.1 polish" 但没有 `## [0.1.1]` 段)。**归 P1**,下次发版前补。
---
## 落地操作(已 commit 到 `audit/appstore-prep` 分支)
| Commit | 内容 |
|---|---|
| `chore(ci): pin Xcode 16.4 + macos-15 runner` | P0-1 |
| `chore(ci): enable strict lint with curated disabled rules` | P1-1 起步 |
| `chore: remove dead AudioCaptureService and clarify LiveDictationController scope` | P0-3 |
| `chore: declare ITSAppUsesNonExemptEncryption=NO for App Store upload` | P0-4 |
| `docs: App Store screenshots placeholder + APPSTORE_METADATA.md` | P0-5 |
| `chore: tag v0.1.2 (App Store submission prep)` | P0-6 |
详细 diff 见 git log。
---
## 真人/外部事项(需皇上或太子手动处理)
1. **App Store Connect 上传** — 需人工在 Xcode → Product → Archive → Distribute App,需要 Apple Developer Team 登录
2. **真实截图替换**`docs/screenshots/*.png` 当前是脚本生成占位;需在 iPhone 17 Pro / Pro Max 模拟器跑出真实 UI 截图
3. **App Store Connect 元数据填写**`docs/APPSTORE_METADATA.md` 含全部文案,需复制到 App Store Connect 后台
4. **Privacy Nutrition Labels** — App Store Connect 隐私标签:勾选 "Data Not Collected",并在 "Health & Fitness / Data Not Used for Tracking" 等子项确认
5. **Encryption 出口合规** — 加了 `ITSAppUsesNonExemptEncryption=NO` 后问卷会跳过;如出现,需要在 ITC 提交 self-classification report
6. **GitHub Release**`git push origin v0.1.2` 后需到 GitHub Releases 页面写 release notes
7. **TestFlight** — 上传后建议先 internal TestFlight 跑一遍,确认 Flow / Onboarding / 键盘添加流程
---
## 附录 A:本地烟雾测试结果
### A.1 xcodebuild build (iPhone 17 Pro / iOS Simulator)
```
xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-configuration Debug -derivedDataPath ./.derivedData build \
CODE_SIGNING_ALLOWED=NO
** BUILD SUCCEEDED **
```
产出 `.derivedData/Build/Products/Debug-iphonesimulator/OSGKeyboard.app/`
包含:
- `OSGKeyboard` (主 App, 39 KB 可执行)
- `OSGKeyboard.debug.dylib` (4.3 MB)
- `PlugIns/OSGKeyboardExt.appex` (键盘扩展)
- `Frameworks/OSGKeyboardShared.framework` (共享 framework)
- `Info.plist``ITSAppUsesNonExemptEncryption=0` 已生效
- 资源 Assets.car / MaterialIcons-Regular.ttf / en.lproj / zh-Hans.lproj
### A.2 xcodebuild test (iPhone 17 Pro / iOS Simulator)
```
xcodebuild test ... -only-testing:OSGKeyboardTests CODE_SIGNING_ALLOWED=NO
Executed 29 tests, with 11 failures (0 unexpected)
```
**通过 20/29** — ASRConversion / DictationBridge / FlowSessionBridge / PreviewASRControllerState 全部 PASS。
**失败 9/29** — 全部为 `errSecMissingEntitlement (-34018)`
- `KeychainTests` (8 tests) — 缺 `keychain-access-groups` entitlement 注入
- `LLMClientTests` (3 tests) — 间接依赖 Keychain
**根因**: `CODE_SIGNING_ALLOWED=NO` 时 xcodebuild 不注入 entitlement,但 Keychain tests 调用了 `kSecAttrAccessGroup` 共享 keychain。这是**测试工程问题**,不是代码 bug,**不影响上架**。
**修法(不本审计范围内,仅记录)**:
1. 在 CI 中允许临时 ad-hoc signing`CODE_SIGN_IDENTITY=-`),保留 entitlement
2. 或在 `KeychainTests` 中使用 `URL(fileURLWithPath:)` mock Keychain,绕过真 keychain query
3. 或在 `OSGKeyboardTests/Info.plist` 同样加 `keychain-access-groups` 数组
推荐方案 1(最小改动;CI 加 `CODE_SIGN_IDENTITY: "-"` + `CODE_SIGN_STYLE: Manual`)。
---
**本审计到此结束。中书省。**
+15 -2
View File
@@ -8,23 +8,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Clipboard history**: optional keyboard clipboard history (latest 15 plain-text items, App Group local), top-bar clipboard entry, history panel with whitespace token chips, and a suggestion strip across voice / typing / AI when enabled. / **剪贴板历史**:可选的键盘剪贴板历史(最近 15 条纯文本、App Group 本地),顶栏剪贴板入口、带空白分词芯片的历史面板,以及开启后在语音 / 打字 / AI 面显示的建议条。
- **AI idle hint carousel**: AI mode empty state rotates one-line suggestions (local evergreen + optional remote hot topics); tap sends the card prompt to the LLM without the mic. Coexists with the clipboard suggestion strip in the top bar. / **AI 空闲建议轮播**:AI 模式空状态轮播单行建议(内置常驻 + 可选远程热点);点按即跳过麦克风把卡片 prompt 发给模型。与顶栏剪贴板建议条并存
- **Hint feed refresh**: main app silently fetches `https://key.osglab.com/hints` about every 12 hours, compresses titles with the users polish LLM, and writes App Group ready packs for the keyboard. / **建议源刷新**:主 App 约每 12 小时静默拉取 `https://key.osglab.com/hints`,用用户润色 LLM 压缩标题,写入 App Group 供键盘只读。
- **Clipboard history**: optional keyboard clipboard history (latest 15 plain-text items, App Group local), top-bar clipboard entry, history panel with whitespace token chips, and a suggestion strip across voice / typing / AI when enabled. Within ~30s of a copy, AI idle hints can prefer clipboard-related cards. / **剪贴板历史**:可选的键盘剪贴板历史(最近 15 条纯文本、App Group 本地),顶栏剪贴板入口、带空白分词芯片的历史面板,以及开启后在语音 / 打字 / AI 面显示的建议条;复制后约 30 秒内 AI 空闲建议可偏向剪贴板相关卡片。
- **Clipboard settings**: Settings home adds a Clipboard page under AI Agent with History and Suggestion-strip toggles (both default off); keyboard deep-links open that page. / **剪贴板设置**:设置首页在 AI Agent 下方新增「剪贴板」页,含历史记录与候选栏展示开关(均默认关闭);键盘可深链打开该页。
- **Paste permission guidance**: the Clipboard settings page explains iOS's durable “Paste from Other Apps” permission and opens iOS Settings directly, so users can set it to Allow once and stop the per-copy paste prompt. / **粘贴授权引导**:剪贴板设置页说明 iOS 的「从其他 App 粘贴」持久授权并可直接跳转系统设置,用户设为「允许」一次即不再每次复制都弹窗。
- **Clipboard settings copy**: shorten history, suggestion-strip, and paste-permission wording to brief user-facing lines. / **剪贴板设置文案**:精简历史、建议条与粘贴授权说明,改为简短面向用户的表述。
- **Settings summary placement**: navigation-row summaries (AI Agent, Clipboard, etc.) sit trailing before the chevron, matching the Version row. / **设置摘要位置**:导航行摘要(AI Agent、剪贴板等)移到右侧 chevron 前,与「版本」行一致。
- **AI answer streaming**: AI mode streams visible answer text into the keyboard as the model writes (all AI-mode transports), with throttled App Group updates, search-fallback draft restart, and a “thinking” status before the first token; dictation polish stays non-streaming. / **AI 回答流式输出**:AI 模式在模型开始写正文后将可见答案增量推送到键盘(覆盖全部 AI 传输路径),经 App Group 节流更新;搜索失败回退会清空半截草稿;首 token 前显示「思考中」;听写润色仍为整段返回。
- **Spoken clipboard requests**: in AI mode, naming the clipboard out loud (“reply to the clipboard”, “translate my clipboard”) now attaches the stored clipboard text to that question. Naming it is the authorization, so no second confirmation appears; every other AI question is still sent without clipboard text, and with Clipboard History off the request fails with a clear reason instead of guessing. / **口述剪贴板指令**:AI 模式中说出「回复剪贴板」「把剪贴板翻译成英文」等,会把已保存的剪贴板正文附加到该问题。说出即视为授权,不再二次确认;其余 AI 提问一律不附带剪贴板;未开启剪贴板历史时会给出明确原因而非自行猜测。
- **AI Agent settings**: Settings home adds an AI Agent row under General, with a Response length preference (Short / Medium / Detailed, default Medium). AI mode injects soft length guidance into the system prompt and syncs the choice via iCloud settings. / **AI Agent 设置**:设置首页在「通用」下方新增 AI Agent 入口,支持「回复篇幅」(简短 / 中等 / 详细,默认中等)。AI 模式将篇幅作为软约束写入 system prompt,并纳入 iCloud 设置同步。
### Changed
- **Home library cards**: History and Personal dictionary leave the bottom dock; Home shows two self-sizing preview cards — transcripts split by hairlines (two lines each) and dictionary terms with their source/usage detail line — that push the existing pages with a standard back button. / **首页资料卡片**:历史记录与个性词库移出底部 Dock;首页改为两张按内容自适应高度的预览卡——历史条目用细线分隔(每条最多两行),词库显示词条与来源/使用次数小字——点按后以标准返回按钮进入原页面。
- **Home scroll status**: engine and session status scroll with the page at the bottom (dock clearance via tab-bar padding) instead of a pinned footer. / **首页滚动状态**:引擎与会话状态随页面滚到底部(Dock 留白与设置页一致),不再钉在底部。
- **Clipboard AI prompt contract**: clipboard text now reaches the model as a separate escaped `clipboard_text` block beside the instruction, and the AI system prompt treats that block as untrusted content instead of instructions. Hint cards carry only the instruction. / **剪贴板 AI 提示词契约**:剪贴板正文改为与指令并列的独立转义 `clipboard_text` 块,AI system prompt 明确将该块视为不可信内容而非指令;建议卡片只保留指令本身。
- **Hint feed freshness**: refresh tracks success per locale (a Chinese success no longer masks an English failure), a manifest failure no longer aborts the packs, and a pack past its `expiresAt` — or older than 48 hours without one — falls back to the built-in evergreen catalog instead of showing stale hot topics. / **建议源新鲜度**:刷新按语言分别记录成功(中文成功不再掩盖英文失败),manifest 失败不再中断整轮;超过 `expiresAt`(或无该字段且超过 48 小时)的建议包回退到内置常驻卡片,不再展示陈旧热点。
- **Privacy policy**: document AI idle suggestions, opt-in clipboard history (panel, suggestion strip, and AI hint use), and when clipboard text is sent to your AI provider. / **隐私政策**:补充 AI 空闲建议、可选剪贴板历史(面板、建议条与 AI 提示用途),以及剪贴板正文在何种情况下会发送给 AI 服务商。
- **Clipboard history row opacity**: history panel entry cards use 50% surface opacity so the keyboard chrome shows through. / **剪贴板历史条目透明度**:历史面板每条记录背景改为 50% 透明度,键盘底色可透出。
- **Translation button chrome**: voice mic-row translation control matches the adjacent undo key (44×44 rounded rectangle) instead of a circular chip. / **翻译按钮样式**:语音麦克风行的翻译控件改为与相邻撤销键一致的 44×44 圆角矩形,不再使用圆形芯片。
- **Undo covers clipboard pastes**: the undo key now rolls back text inserted from the clipboard suggestion strip or history panel, in one step regardless of length. Pasted text stays out of dictation history and is never offered for last-input editing. / **撤销支持剪贴板粘贴**:撤销键现在可回滚从剪贴板建议条或历史面板插入的文字,无论长度都一次撤销到底。粘贴内容不进入听写历史,也不会成为「编辑上次输入」的对象。
- **Undo key label**: rename the accessibility label from “Undo last dictation” to “Undo last input” now that it covers dictation, AI answers, edits and pastes. / **撤销键文案**:无障碍标签由「撤销上次听写」改为「撤销上次输入」,因其现已覆盖听写、AI 答案、编辑与粘贴。
- **Translation chip placement**: move the top-bar translation control onto the voice mic row, mirrored with Undo; the top-bar slot becomes the clipboard button. / **翻译入口位置**:顶栏翻译控件移到语音麦克风行并与撤销对称;原顶栏位置改为剪贴板按钮。
- **AI empty-state tip**: center “Tap the microphone to ask AI” in the answer area (horizontal + vertical). / **AI 空状态指引**:「点击麦克风向 AI 提问」在答案区域水平与垂直居中
- **AI empty-state tip**: center “Tap the microphone to ask AI” in the answer area (horizontal + vertical); idle state now rotates hint cards there. / **AI 空状态指引**:答案区域水平与垂直居中展示空闲建议轮播(替代静态「点击麦克风」文案)
### Fixed
- **Clipboard hints missed a fresh copy**: the AI idle carousel now refreshes from the clipboard store as soon as a copy is captured, instead of waiting for the next 4-second rotation or a re-entry into AI mode — the reason clipboard suggestions often never appeared inside their 30-second window. / **新复制无剪贴板建议**:AI 空闲轮播在采集到新复制时立即依据剪贴板刷新,不再等待下一次 4 秒轮换或重新进入 AI 模式——这正是 30 秒窗口内常常看不到剪贴板建议的原因。
- **Reduce Motion froze hint data**: Reduce Motion now only stops the fade rotation. Cards still refresh, so a new copy appears and a card whose clipboard window closed leaves the carousel. / **Reduce Motion 冻结建议数据**:辅助功能「减弱动态效果」现在只停止渐隐轮换,数据仍会刷新:新复制会出现,剪贴板窗口关闭的卡片会退出轮播。
- **Expired clipboard hint tap**: tapping a clipboard card after its 30-second window fails closed with a “copy the text again” message instead of sending the instruction with empty material. / **过期剪贴板建议点击**:超过 30 秒窗口后点击剪贴板卡片会明确提示重新复制,而不是把缺少材料的指令发出去。
- **Blank keyboard on cross-device clipboard**: clipboard-history capture no longer reads `UIPasteboard` on the main thread or during the appear sequence — a Universal Clipboard item still being fetched from another device blocked the main thread for seconds, freezing the keyboard mid-presentation. Reads now run on a background queue, start on the first poll tick after the slide-in, and never overlap. / **跨设备剪贴板导致键盘空白**:剪贴板历史采集不再在主线程、也不再在键盘出现流程中读取 `UIPasteboard`——待从其他设备拉取的通用剪贴板内容会同步阻塞主线程数秒,使键盘卡在呈现过程中。读取改到后台队列、延后到滑入完成后的首次轮询,且不会重叠执行。
- **Keyboard presentation height**: drop the `target system encapsulated height` priming trick. It read the pre-presentation full-screen height (874 pt on an iPhone) rather than the keyboard slot, clamped our constraint to 0, and lost to the system's required constraint anyway; the surface is now bottom-anchored so a transient over-tall container can never park it above the visible slot. / **键盘呈现高度**:移除「目标高度 − 系统封装高度」的预置技巧。它读到的是呈现前的整屏高度(iPhone 上 874pt)而非键盘槽,把约束夹成 0,且本来就压不过系统的 required 约束;键盘内容改为底部锚定,容器临时过高时不会再被顶到可见区域之外。
- **Undo of long insertions**: caret verification now compares the tail of the inserted text's last line instead of the whole string, so long or multi-line insertions no longer lose undo the moment the host returns a truncated context. / **长文本撤销**:光标校验改为比对插入文本最后一行的尾部而非整段,长文本或多行插入不再因宿主返回截断上下文而立刻失去撤销能力。
- **Undo after editing last input**: a newer insertion now clears the pending edit transaction, so undo rolls back that insertion instead of deleting it and restoring the older edit's original text. / **编辑上次输入后的撤销**:新的插入会清除待撤销的编辑事务,撤销将回滚该次插入,而不再是删除它并还原上一次编辑前的旧文本。
- **AI waiting spinner duplicate**: remove the mini ProgressView beside the AI status caption; the mic button spinner remains the sole loading indicator while recognizing or generating. / **AI 等待转圈重复**:去掉 AI 状态文案旁的迷你 ProgressView;识别/生成中仅保留麦克风按钮上的 loading。
+15 -7
View File
@@ -1,6 +1,10 @@
# Contributing to OSGKeyboard
Thanks for your interest! OSGKeyboard is a small, opinionated iOS app. We welcome bug reports, feature ideas, and pull requests — please read this guide first.
Thanks for your interest! OSGKeyboard is a Swift 6 app for iOS/iPadOS 26+ and macOS 15+. We welcome bug reports, feature ideas, and pull requests — please read this guide first.
## License
OSGKeyboard is source available, not open source. By contributing, you agree to the contribution terms in [`LICENSE`](LICENSE). The license permits personal, non-commercial local builds but does not permit unauthorized redistribution or public derivative versions.
## Code of Conduct
@@ -10,7 +14,7 @@ This project follows the [Contributor Covenant](https://www.contributor-covenant
Open an issue using the **Bug report** template. Please include:
- iOS version + device model
- OS version + device model (iPhone, iPad, or Apple-silicon Mac)
- Xcode version (run `xcodebuild -version`)
- Steps to reproduce
- Relevant logs (Console.app filtered to `OSGKeyboard`)
@@ -29,8 +33,9 @@ Open an issue using the **Feature request** template. Briefly describe:
2. **Generate the project locally:**
```bash
brew install xcodegen swiftlint
xcodegen generate # or: ./Scripts/generate-xcodeproj.sh
./Scripts/generate-xcodeproj.sh
```
Building requires macOS with Xcode 26. `project.yml` is the project source of truth.
3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable.
4. **Tests.** Add XCTest coverage under `OSGKeyboardTests/` (or Ext/Mac targets) for any non-trivial logic, and register the class in **exactly one** group in `Tests/suite-manifest.json`. See [`docs/TESTING.md`](docs/TESTING.md).
5. **Build & test before pushing:**
@@ -44,13 +49,16 @@ Open an issue using the **Feature request** template. Briefly describe:
## Project structure
```
OSGKeyboard/ Main iOS app target
OSGKeyboard/ Main iOS/iPadOS app target and host resources
OSGKeyboardExt/ Custom Keyboard Extension target
OSGKeyboardShared/ Framework shared by app + extension
OSGKeyboardShared/ Lightweight app/extension shared framework
OSGKeyboardHostSupport/ Host-only ASR, cloud, CLM, charts, and StoreKit
OSGKeyboardMac/ macOS menu-bar app; Qwen3 MLX local ASR
OSGKeyboardTests/ XCTest unit tests (host / shared)
OSGKeyboardExtTests/ Keyboard / typing XCTest
OSGKeyboardMacTests/ Mac XCTest
Tests/ Suite manifest (grouped presets — see docs/TESTING.md)
project.yml XcodeGen project definition (source of truth)
project.yml XcodeGen definition; version/build source of truth
.github/workflows/ CI
```
@@ -68,4 +76,4 @@ The simplest contribution: add a preset to `OSGKeyboardShared/Models/LLMProvider
## Releasing
Maintainers cut releases from `main` via GitHub Releases. The release tag follows `vX.Y.Z`. CHANGELOG.md is updated as part of the release PR.
Maintainers cut releases from `main`. The marketing version and monotonic build number live in `project.yml`; `CHANGELOG.md` is updated as part of the release PR. Do not infer the current Mac binary version from the historical download URL in the README.
+90 -1
View File
@@ -29,8 +29,15 @@ struct OSGKeyboardApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
// Approach A: Notes-like host only; real keyboard extension overlays it.
Self.makeWhatsNewHostView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-demo") {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
ThemedRoot {
EditPagerUITestHarness()
@@ -62,4 +69,86 @@ struct OSGKeyboardApp: App {
#endif
}
}
#if DEBUG
@MainActor
private static func makeWhatsNewHostView() -> some View {
let args = ProcessInfo.processInfo.arguments
let scenario = whatsNewScenario(from: args) ?? .edit
let language = whatsNewLanguage(from: args)
let seed = whatsNewSeedText(for: scenario, language: language)
WhatsNewDemoScenario.clear()
WhatsNewDemoScenario.arm(scenario, seedText: seed, language: language)
if let defaults = AppGroup.defaultsIfAvailable {
defaults.set(true, forKey: AppGroupConfiguration.Keys.hasCompletedOnboarding)
// Force extension ExtL10n / SharedL10n into the demo language.
defaults.set(
(language == .en ? AppUILanguage.english : AppUILanguage.chinese).rawValue,
forKey: AppGroupConfiguration.Keys.uiLanguage
)
// Clipboard demo needs history + suggestion strip flags on.
if scenario == .clipboard {
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
defaults.set(
true,
forKey: AppGroupConfiguration.Keys.clipboardCandidateBarEnabled
)
}
defaults.synchronize()
}
return NotesHostDemoView(
scenario: scenario,
seedText: seed,
language: language
)
}
private static func whatsNewScenario(from args: [String]) -> WhatsNewDemoScenario? {
if let paired = args.first(where: { $0.hasPrefix("--whats-new-scenario=") }) {
let raw = String(paired.dropFirst("--whats-new-scenario=".count))
return WhatsNewDemoScenario(rawValue: raw)
}
if let idx = args.firstIndex(of: "--whats-new-scenario"),
args.index(after: idx) < args.endIndex
{
return WhatsNewDemoScenario(rawValue: args[args.index(after: idx)])
}
return nil
}
private static func whatsNewLanguage(from args: [String]) -> WhatsNewDemoScenario.Language {
if let paired = args.first(where: { $0.hasPrefix("--whats-new-lang=") }) {
let raw = String(paired.dropFirst("--whats-new-lang=".count))
return WhatsNewDemoScenario.Language(rawValue: raw) ?? .zh
}
if let idx = args.firstIndex(of: "--whats-new-lang"),
args.index(after: idx) < args.endIndex
{
return WhatsNewDemoScenario.Language(
rawValue: args[args.index(after: idx)]
) ?? .zh
}
return .zh
}
private static func whatsNewSeedText(
for scenario: WhatsNewDemoScenario,
language: WhatsNewDemoScenario.Language
) -> String {
switch (scenario, language) {
case (.edit, .zh):
return "明天下午三点开会讨论方案"
case (.edit, .en):
return "Meeting at 3pm tomorrow to discuss the plan"
case (.ai, .zh):
return "周末想找个地方放松一下"
case (.ai, .en):
return "Looking for a place to relax this weekend"
case (.clipboard, .zh):
return "待办:"
case (.clipboard, .en):
return "Todo: "
}
}
#endif
}
+18 -8
View File
@@ -22,18 +22,22 @@
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
<div id="top">
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> August 10, 2026</p>
<p><strong>Last updated:</strong> August 12, 2026</p>
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. The default on-device mode transcribes locally with Apples speech APIs and does not upload raw audio. If you explicitly enable cloud recognition, recordings are sent to the speech provider you configure for transcription; that providers privacy policy applies. OSGKeyboard does not store or proxy the audio on its own servers.</li>
<li><strong>Transcribed text</strong> — when AI polish is enabled, the final text (not audio) is sent to the LLM provider whose API key you configured (e.g. OpenAI, DeepSeek) for punctuation and formatting. Without an API key, raw ASR text is inserted and no polish request is sent.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
<li><strong>AI idle suggestions</strong> — the main app may periodically download public hint titles (e.g. hot topics) from OSGKeyboards hint feed and, using your configured polish LLM, compress them into short on-device suggestion labels. Suggestion packs are cached in the App Group for the keyboard; the keyboard extension does not fetch the feed itself.</li>
<li><strong>Clipboard history (opt-in)</strong> — when you enable Clipboard History, the keyboard may read plain-text pasteboard content while it is visible and keep recent copies on device for the history panel and optional suggestion strip. Within about 30 seconds after a copy, AI mode may also offer clipboard-related idle suggestions; tapping one sends the clipboard text with that prompt to your configured LLM. Saying “clipboard” in an AI question does the same, because naming it is how you choose that text; every other AI question is sent without it. In both cases the clipboard body travels as separate quoted data, never as instructions. Clipboard history is local-only and not synced via iCloud.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors preferences, usage statistics, and voice history through your private iCloud account.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this devices host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
<li><strong>On-device typing learning</strong> — the Chinese keyboard stores selected words and candidate frequencies in the App Group on your device. OSGKeyboard does not upload this user dictionary.</li>
</ul>
<p>Clipboard sensitive-content filtering is applied to newly captured items. Existing history is retained until you use the confirmed clear action.</p>
<h2>What we do not collect</h2>
<ul>
@@ -46,7 +50,7 @@
<ul>
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
<li><strong>Speech recognition</strong> — required for on-device transcription.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, and communicate with the main app. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, communicate with the main app, and—only when clipboard history is enabled—read clipboard text. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
</ul>
<h2>Third parties</h2>
@@ -55,6 +59,7 @@
<h2>Data retention</h2>
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; preferences, statistics, and history may sync via your private iCloud account.</p>
<p><strong>Voice history</strong> — successful transcripts may be saved in the main apps History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
<p><strong>Clipboard history</strong> — stays in this devices App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
<h2>Contact</h2>
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
@@ -62,17 +67,21 @@
<hr id="zh">
<h1>OSGKeyboard 隐私政策</h1>
<p><strong>更新日期:</strong>2026 年 8 月 10</p>
<p><strong>更新日期:</strong>2026 年 8 月 12</p>
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
<h2>我们处理的数据</h2>
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。默认本地模式通过 Apple 语音能力在设备端转写,不会上传原始录音。若你主动启用云端识别,录音会发送到你配置的语音服务商完成转写,并适用该服务商的隐私政策。OSGKeyboard 自身不会存储或中转音频。</li>
<li><strong>转写文字</strong> — 当你配置了 LLM API Key 并启用润色时,最终文字(非音频)会发送到该服务商以整理标点和格式。未填写 API Key 时直接插入原始识别结果,不会发起润色请求。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
<li><strong>AI 空闲建议</strong> — 主 App 可能定期从 OSGKeyboard 热点建议源下载公开标题,并使用你配置的润色 LLM 压缩为短标签,缓存在 App Group 供键盘读取;键盘扩展本身不会直接请求该源。</li>
<li><strong>剪贴板历史(可选)</strong> — 当你开启「剪贴板历史」后,键盘在可见期间可能读取纯文本粘贴板内容并在本机保存,供历史面板与可选建议条使用。复制后约 30 秒内,AI 模式也可能展示剪贴板相关空闲建议;点选后会将剪贴板正文与提示一并发送到你配置的 LLM。在 AI 提问中明确说出「剪贴板」同样如此——说出即代表你选择了这段材料;其余 AI 提问不会附带剪贴板。两种情况下剪贴板正文都作为单独引用的数据发送,绝不作为指令。剪贴板历史仅存本机,不经 iCloud 同步。</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
</ul>
<p>剪贴板敏感内容过滤仅在新内容采集时执行;已有历史会继续保留,直到你使用带确认的清空操作。</p>
<h2>我们不收集的内容</h2>
<ul>
@@ -85,7 +94,7 @@
<ul>
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
<li><strong>语音识别</strong> — 端侧转写所需。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key与主 App 通信。完全访问不代表我们会收集全部击键内容。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key与主 App 通信,并仅在你开启剪贴板历史后读取剪贴板文字。完全访问不代表我们会收集全部击键内容。</li>
</ul>
<h2>第三方</h2>
@@ -94,6 +103,7 @@
<h2>数据保留</h2>
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
<h2>联系</h2>
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
@@ -0,0 +1,126 @@
// AIHintRefreshService.swift
// OSGKeyboard · Main App
//
// Silent 12h refresh: fetch remote packs, compress titles with polish LLM,
// merge local evergreen cards, write App Group ready packs for the keyboard.
import Foundation
import OSGKeyboardShared
@MainActor
enum AIHintRefreshService {
private static var inFlight: Task<Void, Never>?
static func refreshIfNeeded(reason: String) {
guard AIHintStore.shouldRefresh() else {
OSGDiag.log("AIHintRefresh skip (fresh) reason=\(reason)", category: "hints")
return
}
guard inFlight == nil else {
OSGDiag.log("AIHintRefresh skip (inFlight) reason=\(reason)", category: "hints")
return
}
inFlight = Task {
defer { inFlight = nil }
await runRefresh(reason: reason)
}
}
private static func runRefresh(reason: String) async {
AIHintStore.markAttempt()
OSGDiag.log("AIHintRefresh start reason=\(reason)", category: "hints")
// The manifest only supplies fallback dates, so a manifest failure must
// not stop the packs, and one locale's failure must not stop the other.
let manifest = try? await fetchManifest()
for locale in AIHintFeedEndpoints.supportedLocales {
if Task.isCancelled { return }
guard AIHintStore.shouldRefresh(locale: locale) else { continue }
do {
let pack = try await readyPack(locale: locale, manifest: manifest)
AIHintStore.saveReadyPack(pack)
OSGDiag.log(
"AIHintRefresh wrote locale=\(locale) cards=\(pack.cards.count)",
category: "hints"
)
} catch {
// Strategy B: keep this locale's previous successful ready pack.
OSGDiag.log(
"AIHintRefresh failed locale=\(locale) reason=\(reason) "
+ "error=\(error.localizedDescription)",
category: "hints"
)
}
}
}
private static func readyPack(
locale: String,
manifest: AIHintManifest?
) async throws -> AIHintPack {
let remote = try await fetchPack(locale: locale)
let filtered = remote.cards.filter { card in
let hay = card.displayText + card.prompt
return !hay.contains("历史上的今天")
&& !hay.localizedCaseInsensitiveContains("on this day")
}
// Prefer remote clipboard cards when present; always keep local evergreen.
let merged = merge(remote: filtered, locale: locale)
let compressed = await AIHintKeywordCompressor().compress(
cards: merged,
locale: locale
)
return AIHintPack(
locale: locale,
generatedAt: remote.generatedAt ?? manifest?.generatedAt,
expiresAt: remote.expiresAt ?? manifest?.expiresAt,
version: max(remote.version, 1),
cards: compressed,
refreshedAt: Date()
)
}
private static func merge(remote: [AIHintCard], locale: String) -> [AIHintCard] {
var byID: [String: AIHintCard] = [:]
for card in AIHintLocalCatalog.cards(locale: locale) {
byID[card.id] = card
}
for card in remote {
// Local clipboard display/prompt stays authoritative when ids collide
// with baseline remote cards; otherwise remote wins for hot topics.
if card.requiresClipboard30s, byID[card.id] != nil {
continue
}
if card.source == "local", byID.keys.contains(where: { $0.hasPrefix("local-\(locale)-") }) {
// Drop remote baseline duplicates when we already have local evergreen.
if ["clipboard", "capability", "economy"].contains(card.category) {
continue
}
}
byID[card.id] = card
}
return Array(byID.values).sorted { $0.priority > $1.priority }
}
private static func fetchManifest() async throws -> AIHintManifest {
let (data, response) = try await URLSession.shared.data(from: AIHintFeedEndpoints.manifestURL)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintManifest.self, from: data)
}
private static func fetchPack(locale: String) async throws -> AIHintPack {
let url = AIHintFeedEndpoints.packURL(locale: locale)
let (data, response) = try await URLSession.shared.data(from: url)
try validateHTTP(response)
return try JSONDecoder().decode(AIHintPack.self, from: data)
}
private static func validateHTTP(_ response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
// AppPermissions.swift
// OSGKeyboard · Main App
//
// Central permission status for onboarding and Flow session startup.
// Central permission handling for onboarding, Flow, and clipboard access.
import AVFoundation
import Speech
@@ -78,6 +78,16 @@ enum AppPermissions {
UIApplication.shared.open(url)
}
/// Performs an explicit direct read so iOS can present paste authorization
/// and create the app's "Paste from Other Apps" settings entry.
@MainActor
@discardableResult
static func requestPasteAccess() -> Bool {
let pasteboard = UIPasteboard.general
guard pasteboard.hasStrings else { return false }
return pasteboard.string != nil
}
/// Home-screen guidance when Flow permissions are missing after onboarding.
static var homePermissionGuidanceMessage: String {
let micMissing = micStatus != .granted
+204 -94
View File
@@ -46,7 +46,7 @@ final class FlowSessionManager: ObservableObject {
private let polisher = PolishingService()
/// AI-mode turns are intentionally process-local and never persisted.
private let aiConversations = AIConversationStore()
/// Cached ASR instance. v0.2.0: the only on-device backend is iOS
/// Cached ASR instance. The only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step we can hand the
/// factory-built service straight back without going through the
/// old `OnDeviceModelWarmup` registry.
@@ -550,7 +550,7 @@ final class FlowSessionManager: ObservableObject {
Task { @MainActor [weak self] in
await self?.reactivateCaptureIfNeeded()
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
// iOS `SpeechAnalyzer` is bundled with the OS; no
// on-device weights to reload after a background trip.
self?.bindSessionASRIfNeeded()
// ASR warmup stays on first mic press never on foreground bounce.
@@ -1283,6 +1283,168 @@ final class FlowSessionManager: ObservableObject {
if let conversationID = command.aiConversationID {
Task { await aiConversations.removeConversation(conversationID) }
}
case .submitAIQuestion:
guard command.resolvedUtteranceMode == .aiQuestion else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let question = command.aiQuestionText?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !question.isEmpty else {
storeRejectedStart(
command,
message: AppL10n.string("flow.error.aiQuestionFailed"),
status: .error
)
return
}
let startDecision = FlowStartTransactionPolicy.decide(
incomingUtteranceID: command.utteranceId,
deadlineAt: command.startDeadlineAt,
hostState: hostUtteranceState
)
switch startDecision {
case .idempotent:
refreshHostReady()
return
case .rejectBusy, .rejectExpired:
let status: FlowResult.Status =
startDecision == .rejectExpired ? .timeout : .error
storeRejectedStart(
command,
message: AppL10n.string("flow.error.recognitionInterrupted"),
status: status
)
return
case .accept:
break
}
guard prepareUtteranceIdentity(
utteranceId: command.utteranceId,
commandSeq: command.commandSeq
) else { return }
currentUtteranceMode = .aiQuestion
pendingAIConversationID = command.aiConversationID
pendingEditSourceText = nil
pendingSourceHistoryEntryID = nil
pendingSourceHistoryEntryRevision = nil
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
isUtteranceRecording = false
isUtteranceProcessing = true
refreshHostReady()
let conversationID = command.aiConversationID
let utteranceId = command.utteranceId
let commandSeq = command.commandSeq
let sessionId = command.sessionId
Task { @MainActor [weak self] in
await self?.answerPrefilledAIQuestion(
question: question,
conversationID: conversationID,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
}
}
}
/// Clipboard body for an explicitly spoken clipboard request. Opt-in
/// history is the authorization gate, and while the keyboard is visible its
/// newest stored item is the live pasteboard. Unlike the idle hint cards,
/// naming the clipboard out loud is not bound to the 30s hint window.
private func clipboardMaterialForAIQuestion(store: AppGroupStore) -> String? {
guard store.clipboardHistoryEnabled else { return nil }
return ClipboardHistoryStore().newestEntry?.text
}
/// Skip ASR and answer a prefilled AI hint / typed question.
private func answerPrefilledAIQuestion(
question: String,
conversationID: UUID?,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64
) async {
guard let conversationID else {
guard claimTerminal(utteranceId: utteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
return
}
storeRawCandidate(
question,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq
)
let pipelineStore = AppGroupStore()
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
conversations: aiConversations
)
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
let answer = try await service.answer(
question: question,
conversationID: conversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
Task { @MainActor in
self?.publishStreamingAIAnswerIfNeeded(
partial,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: partial.isEmpty
)
}
}
publishStreamingAIAnswerIfNeeded(
answer,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID,
force: true
)
guard claimTerminal(utteranceId: utteranceId) else { return }
await service.commitSuccessfulTurn(
question: question,
answer: answer,
conversationID: conversationID
)
storeFinalizedResult(
answer,
warning: nil,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
} catch {
guard claimTerminal(utteranceId: utteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.aiQuestionFailed"),
kind: .generic,
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: commandSeq,
aiConversationID: conversationID
)
}
}
@@ -1398,30 +1560,6 @@ final class FlowSessionManager: ObservableObject {
)
}
private func storeCurrentFinal(_ text: String, warning: String? = nil) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
storeCurrentError(AppL10n.string("flow.error.noSpeech"), kind: .noSpeech)
return
}
guard let activeSessionId, let currentUtteranceId else { return }
FlowSessionBridge.writeResult(
FlowResult(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: currentCommandSeq,
status: .final,
text: trimmed,
warning: warning,
rawText: trimmed,
hostGeneration: FlowSessionBridge.currentHostGeneration(),
revision: Self.resultRevision(),
utteranceMode: currentUtteranceMode,
aiConversationID: pendingAIConversationID
)
)
}
private func storeCurrentError(
_ message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
@@ -1777,16 +1915,19 @@ final class FlowSessionManager: ObservableObject {
manager.currentPartial = ""
case .failure(let message):
manager.asrFailureMessage = message
manager.debug("asr error: \(message)")
manager.debug(
"asr error category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.outcome.failed",
"engine=\(manager.store.engineMode) streaming=\(useStreaming ? 1 : 0) "
+ "partialLen=\(manager.currentPartial.count) "
+ "bestPartialLen=\(manager.bestPartialSnapshot.count) error=\(message)"
+ "bestPartialLen=\(manager.bestPartialSnapshot.count) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
// Prefer any non-empty partial over a hard no-speech failure.
// finishProcessing used to clear bestPartialSnapshot and race
// finalize into an empty transcript even when ASR had text.
// Clearing `bestPartialSnapshot` here would race finalize
// into an empty transcript even when ASR had usable text.
let recovery = [
manager.currentPartial,
manager.bestPartialSnapshot
@@ -1976,39 +2117,6 @@ final class FlowSessionManager: ObservableObject {
debug("utterance failed: \(message)")
}
private func finishProcessing(
withError message: String,
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
) {
guard claimTerminal(utteranceId: currentUtteranceId) else { return }
isUtteranceProcessing = false
startingUtteranceId = nil
startTransactionDeadlineAt = nil
FlowSessionBridge.clearStartTransaction()
utteranceRecordingStartedAt = nil
utteranceSafetyTask?.cancel()
utteranceSafetyTask = nil
finalizeTask?.cancel()
finalizeTask = nil
chunkedPipeline = nil
capture.cancelUtterance()
releaseCaptureAfterPiPUtteranceIfNeeded()
currentPartial = ""
lastFinal = ""
lastFinalWithPauseMarks = ""
bestPartialSnapshot = ""
utterancePCMSamples = []
chunkWarnings = []
storeCurrentError(message, kind: kind)
pendingFieldContext = nil
clearPendingInstructionState()
utteranceGeneration &+= 1
currentUtteranceId = nil
currentCommandSeq = 0
refreshHostReady()
debug("utterance processing failed: \(message)")
}
private func claimTerminal(utteranceId: UUID?) -> Bool {
guard let utteranceId, !terminalUtteranceIds.contains(utteranceId) else {
return false
@@ -2207,6 +2315,23 @@ final class FlowSessionManager: ObservableObject {
return
}
let spoken = AIClipboardPrompt.resolveSpoken(
question: text,
material: clipboardMaterialForAIQuestion(store: pipelineStore)
)
guard case .ready(let question) = spoken else {
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
AppL10n.string("flow.error.clipboardUnavailable"),
kind: .generic,
sessionId: finalizeSessionId,
utteranceId: finalizeUtteranceId,
commandSeq: finalizeCommandSeq,
aiConversationID: aiConversationID
)
return
}
do {
let service = try AIQuestionService.configured(
store: pipelineStore,
@@ -2218,7 +2343,7 @@ final class FlowSessionManager: ObservableObject {
let publishCommandSeq = finalizeCommandSeq
let publishConversationID = aiConversationID
let answer = try await service.answer(
question: text,
question: question,
conversationID: aiConversationID,
targetLocaleID: pipelineStore.translationTargetLocaleId
) { [weak self] partial in
@@ -2244,7 +2369,7 @@ final class FlowSessionManager: ObservableObject {
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
await service.commitSuccessfulTurn(
question: text,
question: question,
answer: answer,
conversationID: aiConversationID
)
@@ -2404,13 +2529,13 @@ final class FlowSessionManager: ObservableObject {
if isInstructionMode {
FlowDiagnostics.log(
"instruction edit failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"editLastInput.failed",
"elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
+ "\(Self.safeErrorLogMetadata(error))"
)
guard claimTerminal(utteranceId: finalizeUtteranceId) else { return }
storeFinalizedError(
@@ -2432,14 +2557,14 @@ final class FlowSessionManager: ObservableObject {
)
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
"\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.warn(
"polish.failed",
"mode=\(Self.polishModeLogLabel(polishMode)) engine=\(engineMode) "
+ "elapsed=\(FlowTrace.seconds(since: polishStarted))s "
+ "cancelled=\(error is CancellationError ? 1 : 0) "
+ "error=\(error.localizedDescription)"
+ "\(Self.safeErrorLogMetadata(error))"
)
FlowTrace.transcript(
"polish.fallback",
@@ -2651,7 +2776,7 @@ final class FlowSessionManager: ObservableObject {
"host.deliveredError",
"kind=\(kind.rawValue) status=\(status.rawValue) "
+ "utterance=\(utteranceId.uuidString.prefix(8)) commandSeq=\(commandSeq) "
+ "message=\(message)"
+ "messageBytes=\(message.utf8.count)"
)
FlowSessionBridge.writeResult(
FlowResult(
@@ -2775,10 +2900,13 @@ final class FlowSessionManager: ObservableObject {
)
return resolved
case .failure(let message):
FlowDiagnostics.log("batch fallback failed: \(message)")
FlowDiagnostics.log(
"batch fallback failed category=asrFailure errorBytes=\(message.utf8.count)"
)
FlowTrace.warn(
"asr.batchFallback.failed",
"samples=\(samples.count) rms=\(FlowTrace.rms(samples)) error=\(message)"
"samples=\(samples.count) rms=\(FlowTrace.rms(samples)) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
return currentText
case .cancelled:
@@ -2788,7 +2916,7 @@ final class FlowSessionManager: ObservableObject {
}
private func asrWaitTimeout() -> TimeInterval {
// v0.2.0: local engine is iOS `SpeechAnalyzer` only, so the
// Local engine is iOS `SpeechAnalyzer` only, so the
// previous Qwen3-specific timeout collapses into the shared
// local path.
if store.engineMode == "local" {
@@ -2866,27 +2994,9 @@ final class FlowSessionManager: ObservableObject {
FlowDiagnostics.log(message)
}
// MARK: - Temporary Flow debug panel (remove after orange-mic investigation)
/// Snapshot for the on-screen debug panel. Safe to call from the main actor.
func makeDebugRows() -> [FlowDebugRow] {
let snapshot = FlowSessionBridge.readySnapshot()
let hostRows = FlowDebugAppGroupSnapshot.rows()
let memRows: [FlowDebugRow] = [
FlowDebugRow("isActive", isActive ? "1" : "0"),
FlowDebugRow("isStarting", isStarting ? "1" : "0"),
FlowDebugRow("coldStart", isColdStartHandoff ? "1" : "0"),
FlowDebugRow("engineLive", capture.engineIsLive ? "1" : "0"),
FlowDebugRow("audioFresh", capture.engineHasRecentAudio(maxAge: 2) ? "1" : "0"),
FlowDebugRow("mem.reason", snapshot?.reason.rawValue ?? "nil"),
FlowDebugRow("utt.rec", isUtteranceRecording ? "1" : "0"),
FlowDebugRow("utt.proc", isUtteranceProcessing ? "1" : "0"),
FlowDebugRow("sessionId", activeSessionId.map { String($0.uuidString.prefix(8)) } ?? "nil"),
FlowDebugRow("warning", sessionWarning == nil ? "0" : "1"),
FlowDebugRow("bridgeReady", FlowSessionBridge.isHostReady() ? "1" : "0")
]
// Prefer App Group snap.reason near the top of the shared block.
return memRows + hostRows
private static func safeErrorLogMetadata(_ error: Error) -> String {
"errorCategory=\(String(reflecting: type(of: error))) "
+ "errorBytes=\(error.localizedDescription.utf8.count)"
}
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
+133
View File
@@ -0,0 +1,133 @@
// AIKeyboardDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// What's New 1.7.0 recording host. Uses the **real** AI Agent settings page
// and the **real** `AIKeyboardView` (compiled into the app target) driven by a
// scripted `KeyboardState` no ASR / LLM. Launch with `--ai-demo`.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct AIKeyboardDemoView: View {
private enum Scene: Equatable {
case settings
case keyboard
}
private static let question = "周末去哪儿玩比较合适?"
private static let answer =
"可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。若想轻松一点,选人少的湖边步道也很合适。"
@StateObject private var config = ProviderConfig.shared
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@State private var scene: Scene = .settings
@State private var levelTick = 0.35
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
// Both scenes sit on the bottom band so what's-new crop matches Ext chrome.
VStack(spacing: 0) {
Spacer(minLength: 0)
switch scene {
case .settings:
ThemedRoot {
NavigationStack {
AIAgentSettingsView(config: config)
}
}
.preferredColorScheme(.light)
.frame(maxWidth: .infinity)
// Keep under what's-new crop (~327 pt visible at 3x).
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal, 8)
.padding(.bottom, 24)
.transition(.opacity)
case .keyboard:
AIKeyboardView(
state: state,
typing: typing,
onInsert: { _ in }
)
.background(Palette.light.background.ignoresSafeArea(edges: .bottom))
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
}
.environment(\.locale, Locale(identifier: "zh-Hans"))
.task { await runTimeline() }
}
// MARK: - Scripted timeline (real view models)
private func runTimeline() async {
prepareKeyboardState()
config.uiLanguage = .chinese
config.aiResponseLength = .medium
try? await sleep(2.4)
withAnimation(.easeInOut(duration: 0.35)) {
scene = .keyboard
}
try? await sleep(1.2)
let utteranceID = UUID()
state.aiSession.enter()
state.aiSession.beginPreparing(utteranceID: utteranceID)
try? await sleep(0.35)
state.aiSession.beginListening(utteranceID: utteranceID)
state.level = 0.4
for _ in 0..<8 {
try? await sleep(0.2)
levelTick = Double.random(in: 0.25...0.9)
state.level = levelTick
state.aiSession.updateTranscript(Self.question, utteranceID: utteranceID)
}
state.aiSession.beginRecognizing(utteranceID: utteranceID)
try? await sleep(0.55)
state.aiSession.beginGenerating(question: Self.question, utteranceID: utteranceID)
try? await sleep(0.7)
// Progressive draft so the real answer area updates like production.
let chars = Array(Self.answer)
var index = 0
let step = 4
while index < chars.count {
index = min(index + step, chars.count)
state.aiSession.receivePartialAnswer(
String(chars[..<index]),
utteranceID: utteranceID
)
try? await sleep(0.05)
}
state.aiSession.receiveAnswer(Self.answer, utteranceID: utteranceID)
try? await sleep(1.0)
state.aiSession.markAnswerInserted(offersSend: true)
try? await sleep(1.1)
state.aiSession.markAnswerSent()
try? await sleep(1.6)
}
private func prepareKeyboardState() {
state.surface = .ai
state.aiServiceAvailable = true
state.micDisabled = false
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.micVoiceAvailability = .ready
state.aiSession.enter()
}
/// Slow-mo for screenshot-sequence recording.
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
}
#endif
+37 -13
View File
@@ -35,13 +35,22 @@ struct APISettingsCard: View {
title: AppL10n.string("api.model"),
placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
model: $config.model,
fetchModels: fetchModels
providerIdentity: config.providerId,
endpointIdentity: config.baseURL,
credentialIdentity: config.apiKey,
makeFetchModelsRequest: makeFetchModelsRequest
)
.id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
SettingsProviderToolsRow(
providerIdentity: config.providerId,
endpointIdentity: config.baseURL,
credentialIdentity: config.apiKey,
modelIdentity: config.model,
makeValidateRequest: makeValidateRequest
)
}
.surfaceCard(enabled: showsSurface)
}
@@ -72,25 +81,40 @@ struct APISettingsCard: View {
.settingsListRow()
}
private func validateConnection() async throws {
@MainActor
private func makeValidateRequest() -> ProviderToolRequest<Void> {
// Use on-screen config not a fresh AppGroupStore so a just-typed
// key is visible even if Keychain write is still settling.
let providerID = config.providerId
let baseURL = config.baseURL
let apiKey = config.apiKey
let model = config.model
let thinkingEnabled = config.llmThinkingEnabled
return ProviderToolRequest(providerIdentity: providerID) {
let client = LLMClientFactory.make(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
model: config.model,
thinkingEnabled: config.llmThinkingEnabled
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: thinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
}
private func fetchModels() async throws -> [String] {
@MainActor
private func makeFetchModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = config.providerId
let baseURL = config.baseURL
let apiKey = config.apiKey
let currentModel = config.model
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
}
+30 -20
View File
@@ -22,7 +22,13 @@ struct ASRSettingsCard: View {
genericRows
}
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
SettingsProviderToolsRow(
providerIdentity: config.asrProviderId,
endpointIdentity: config.asrBaseURL,
credentialIdentity: config.asrApiKey,
modelIdentity: config.asrModel,
makeValidateRequest: makeValidateRequest
)
}
.surfaceCard(enabled: showsSurface)
}
@@ -51,7 +57,10 @@ struct ASRSettingsCard: View {
title: AppL10n.string("settings.asr.model"),
placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId),
model: $config.asrModel,
fetchModels: fetchModels
providerIdentity: config.asrProviderId,
endpointIdentity: config.asrBaseURL,
credentialIdentity: config.asrApiKey,
makeFetchModelsRequest: makeFetchModelsRequest
)
.id(config.asrProviderId)
}
@@ -108,16 +117,6 @@ struct ASRSettingsCard: View {
isMonospaced: true
)
}
rowDivider
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
Text(volcengineFields.usesAPIKeyAuth
? "settings.asr.volcengine.note.apiKey"
: "settings.asr.volcengine.note.appToken")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -152,18 +151,29 @@ struct ASRSettingsCard: View {
config.asrApiKey = fields.encodedAPIKey
}
private func validateConnection() async throws {
@MainActor
private func makeValidateRequest() -> ProviderToolRequest<Void> {
let persisted = AppGroupStore()
let live = LiveConfigurationStore(config: config, fallback: persisted)
try await CloudASRConnectionCheck.validate(store: live)
let store = LiveConfigurationStore(config: config, fallback: persisted)
let providerID = config.asrProviderId
return ProviderToolRequest(providerIdentity: providerID) {
try await CloudASRConnectionCheck.validate(store: store)
}
}
private func fetchModels() async throws -> [String] {
@MainActor
private func makeFetchModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = config.asrProviderId
let baseURL = config.asrBaseURL
let apiKey = config.asrApiKey
let currentModel = config.asrModel
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listASRModels(
providerId: config.asrProviderId,
baseURL: config.asrBaseURL,
apiKey: config.asrApiKey,
currentModel: config.asrModel
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
}
@@ -0,0 +1,265 @@
// ClipboardHistoryDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// What's New 1.7.0 recording host. Voice chrome + clipboard panel are the
// **real** extension views (`KeyboardTopControls`, `RecordButton`,
// `KeyboardTranslationMenuButton`, `ClipboardHistoryPanelView`, ) driven by
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct ClipboardHistoryDemoView: View {
private enum Layout {
static let micSize: CGFloat = 121
static let undoSize: CGFloat = 44
static let micToButtonGap: CGFloat = 8
static let actionClusterTopGap: CGFloat = Spacing.xl
static let micUpwardAdjustment: CGFloat =
(actionClusterTopGap - micToButtonGap) / 2
}
@StateObject private var state = KeyboardState()
@StateObject private var typing = TypingSessionController()
@StateObject private var history = ClipboardHistoryStore(
defaults: UserDefaults(suiteName: "osg.whatsnew.clipboard.demo")
)
@Environment(\.colorScheme) private var colorScheme
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
keyboardChrome
.background(palette.background.ignoresSafeArea(edges: .bottom))
.overlay(alignment: .top) {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
}
}
.environment(\.themePalette, palette)
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.task { await runTimeline() }
}
// MARK: - Real keyboard chrome (voice + clipboard overlay)
private var keyboardChrome: some View {
ZStack {
voiceSurface
.opacity(state.clipboardOverlay == .none ? 1 : 0)
.allowsHitTesting(state.clipboardOverlay == .none)
if state.clipboardOverlay == .historyPanel {
ClipboardHistoryPanelView(
history: history,
onClose: { state.clipboardOverlay = .none },
onClear: { history.clearAll() },
onInsert: { text in
state.clipboardSuggestionText = text
state.clipboardOverlay = .none
},
onDelete: { history.remove(id: $0) },
pastePermissionHint: nil
)
}
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: .infinity)
.frame(height: KeyboardChromeLayout.totalHeight)
.padding(.bottom, 24)
.environment(\.themePalette, palette)
}
private var voiceSurface: some View {
VStack(spacing: 0) {
topBar.frame(height: KeyboardTopBarMetrics.height)
Color.clear.frame(height: Layout.actionClusterTopGap)
Spacer(minLength: 0)
micActionRow
}
.frame(maxWidth: KeyboardChromeLayout.voiceContentMaxWidth)
.frame(maxWidth: .infinity)
}
private var topBar: some View {
HStack(spacing: Spacing.xs) {
if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
ClipboardSuggestionBar(
text: suggestion,
onInsert: {},
onDismiss: { state.clipboardSuggestionText = nil }
)
} else {
KeyboardBrandLogo(action: {})
Spacer(minLength: 0)
KeyboardTopControls(
state: state,
typing: typing,
palette: palette,
onInsert: { _ in }
)
}
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
}
private var micActionRow: some View {
VStack(spacing: Layout.micToButtonGap) {
HStack(spacing: 0) {
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .leading) {
demoKey(
systemName: "arrow.uturn.backward",
width: Layout.undoSize,
height: Layout.undoSize
)
.offset(y: -Layout.micUpwardAdjustment)
}
RecordButton(
phase: .idleReady,
level: 0,
isEnabled: true,
onToggle: {},
onPressingChanged: { _ in },
onEditLongPressBegan: nil
)
.frame(width: Layout.micSize, height: Layout.micSize)
.offset(y: -Layout.micUpwardAdjustment)
Color.clear
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(alignment: .trailing) {
KeyboardTranslationMenuButton(
palette: palette,
targetLocaleId: TranslationLanguageCatalog.offLocaleId,
onSelect: { _ in }
)
.equatable()
.frame(width: Layout.undoSize, height: Layout.undoSize)
.offset(y: -Layout.micUpwardAdjustment)
}
}
.frame(height: Layout.micSize)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardChromeLayout.actionKeySpacing) {
demoKey(systemName: "delete.backward", width: widths.side)
demoKey(
title: ExtL10n.string("common.newline"),
width: widths.center
)
demoKey(spaceStyle: true, width: widths.side2)
}
}
.frame(height: KeyboardChromeLayout.actionKeyHeight)
}
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
}
/// Same chrome as Ext `RectangularToolbarButton` / native key surface.
private func demoKey(
systemName: String? = nil,
title: String? = nil,
spaceStyle: Bool = false,
width: CGFloat,
height: CGFloat = KeyboardChromeLayout.actionKeyHeight
) -> some View {
NativeKeyboardKeySurface(
isPressed: false,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
Group {
if spaceStyle {
Capsule()
.fill(NativeKeyboardKeyColors.text(for: colorScheme).opacity(0.22))
.frame(width: 31, height: 4)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
} else if let title {
Text(title)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(NativeKeyboardKeyColors.text(for: colorScheme))
}
}
}
.frame(width: width, height: height)
}
// MARK: - Timeline
private func runTimeline() async {
prepareState()
seedHistory()
// Hold on voice idle so translation + clipboard chip are readable.
try? await sleep(2.2)
withAnimation(.easeInOut(duration: 0.2)) {
state.clipboardOverlay = .historyPanel
}
try? await sleep(2.0)
if let head = history.newestEntry {
withAnimation(.easeInOut(duration: 0.25)) {
state.clipboardSuggestionText = head.text
state.clipboardOverlay = .none
}
}
try? await sleep(2.4)
}
private func prepareState() {
state.surface = .voice
state.micVoiceAvailability = .ready
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.showsSystemGlobeKey = false
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.openClipboardPanel = {
state.clipboardOverlay = .historyPanel
}
state.dismissClipboardOverlay = {
state.clipboardOverlay = .none
}
state.insertClipboardText = { text in
state.clipboardSuggestionText = text
state.clipboardOverlay = .none
}
}
private func seedHistory() {
history.clearAll()
_ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3)
_ = history.ingest(rawText: "https://osglab.com", changeCount: 2)
_ = history.ingest(rawText: "明天下午三点会议室见", changeCount: 1)
history.reload()
}
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
}
#endif
@@ -2,15 +2,17 @@
// OSGKeyboard · Main App
//
// Observes usage + dictionary counts and feeds the shared
// `UsageStatsCluster` (phone stacked / iPad split).
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header`
// (e.g. glass preview field) sits on the 7-day chart card.
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardHostSupport
struct HomeUsageStatsSection: View {
let layout: UsageStatsCluster.Layout
struct HomeUsageStatsSection<Header: View>: View {
let layout: UsageStatsClusterLayout
var compact: Bool = false
@ViewBuilder var header: () -> Header
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@@ -26,7 +28,8 @@ struct HomeUsageStatsSection: View {
dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: dictionaryCount,
compact: compact
compact: compact,
header: header
)
.onAppear(perform: refreshDictionaryCount)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
@@ -45,6 +48,12 @@ struct HomeUsageStatsSection: View {
}
}
extension HomeUsageStatsSection where Header == EmptyView {
init(layout: UsageStatsClusterLayout, compact: Bool = false) {
self.init(layout: layout, compact: compact, header: { EmptyView() })
}
}
#if DEBUG
#Preview("Phone stacked") {
ThemedRoot {
@@ -1,25 +1,22 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar five icons, no labels.
// Bottom tab bar three icons, no labels.
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
// behind the dock refracts through on scroll.
// History + dictionary live as Home cards (not dock tabs).
import SwiftUI
import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
case history
case dictionary
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .history: return .menuBook // unused history uses SF Symbol
case .dictionary: return .menuBook // unused dictionary uses SF Symbol
case .styles: return .menuBook // unused styles uses SF Symbol
case .settings: return .settings
}
@@ -28,8 +25,6 @@ enum AppTab: Int, CaseIterable {
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
default: return nil
}
@@ -38,8 +33,6 @@ enum AppTab: Int, CaseIterable {
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .history: return "tab.history"
case .dictionary: return "tab.dictionary"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
@@ -51,8 +44,6 @@ enum AppTab: Int, CaseIterable {
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
case .history: return "clock.arrow.circlepath"
case .dictionary: return "character.book.closed"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
@@ -62,13 +53,17 @@ enum AppTab: Int, CaseIterable {
struct MinimalTabBar: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@Namespace private var selectionGlassNamespace
@Binding var selection: AppTab
var body: some View {
GlassEffectContainer(spacing: 0) {
HStack(spacing: 0) {
ForEach(AppTab.allCases, id: \.rawValue) { tab in
Button {
withAnimation(Motion.quick) { selection = tab }
withAnimation(Motion.soft) {
selection = tab
}
} label: {
Group {
if let sfSymbol = tab.sfSymbol {
@@ -81,6 +76,29 @@ struct MinimalTabBar: View {
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: 48)
.background {
if selection == tab {
// Capsule, not circle: a circle would inscribe to the
// smaller edge and leave the tab slot looking empty.
Color.clear
.frame(width: 52, height: 44)
.glassEffect(
.regular
.tint(palette.accent.opacity(0.18))
.interactive(),
in: .capsule
)
.glassEffectID(
"main-tab-selection",
in: selectionGlassNamespace
)
.glassEffectTransition(.matchedGeometry)
.matchedGeometryEffect(
id: "main-tab-selection",
in: selectionGlassNamespace
)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -91,7 +109,8 @@ struct MinimalTabBar: View {
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 336)
}
.frame(maxWidth: 280)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, Spacing.xs)
}
+95 -196
View File
@@ -1,28 +1,26 @@
// EditDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Scripted, keyboard-sized recreation of the extension's `LastInputEditView`
// used ONLY to record the "Edit last input" What's New clip in the simulator.
// It reuses the real shared `EditTextPager`, design tokens, and the real
// `EditSessionState` machine, and steps a fixed timeline (idle hint listening
// processing review swipe apply) with canned text no ASR, no LLM.
// Launched via `--edit-demo` (see OSGKeyboardApp). Not shipped in Release.
// What's New "Edit last input" recording host. Uses the **real**
// `LastInputEditView` (compiled into the app target) driven by a scripted
// `KeyboardState` no ASR / LLM. Opening beat shows the voice mic + hint.
// Launch with `--edit-demo`. Not shipped in Release.
#if DEBUG
import SwiftUI
import OSGKeyboardShared
struct EditDemoView: View {
// Canned material for the clip.
private static let originalText = "明天下午三点开会讨论方案"
private static let editedText = "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
private let palette = Palette.light
private enum Scene: Equatable {
case hint
case editing
}
@State private var editSession: EditSessionState = .inactive
@State private var showHint = true
@State private var selectedPage: Int? = 0
@State private var remainingSeconds = 59
@StateObject private var state = KeyboardState()
@State private var scene: Scene = .hint
private var source: EditSessionSource {
let reference = EditableInputReference(
@@ -34,13 +32,22 @@ struct EditDemoView: View {
return EditSessionSource(reference: reference)
}
private var palette: ThemePalette { Palette.light }
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
keyboardPanel
.background(panelBackground)
Group {
switch scene {
case .hint:
hintPanel
case .editing:
LastInputEditView(state: state)
}
}
.background(palette.background.ignoresSafeArea(edges: .bottom))
.overlay(alignment: .top) {
Rectangle()
.fill(palette.divider)
@@ -49,26 +56,36 @@ struct EditDemoView: View {
}
}
.environment(\.themePalette, palette)
.environment(\.locale, Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.task { await runTimeline() }
}
private var panelBackground: some View {
palette.background.ignoresSafeArea(edges: .bottom)
}
// MARK: - Opening hint (voice mic + real copy)
// MARK: - Panel (mirrors LastInputEditView layout)
private var keyboardPanel: some View {
VStack(spacing: 0) {
topBar.frame(height: 44)
if showHint {
hintBody
} else {
pages.frame(height: 144)
statusLine.frame(height: 18)
pageIndicator.frame(height: 12)
primaryRow.frame(height: 55)
private var hintPanel: some View {
VStack(spacing: Spacing.sm) {
HStack {
KeyboardBrandLogo(action: {})
Spacer(minLength: 0)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
.frame(height: KeyboardTopBarMetrics.height)
Spacer(minLength: 0)
Text(ExtL10n.string("keyboard.edit.hint.available"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.accent)
RecordButton(
phase: .idleReady,
level: 0,
isEnabled: true,
onToggle: {},
onPressingChanged: { _ in },
onEditLongPressBegan: nil
)
.frame(width: 121, height: 121)
Spacer(minLength: 0)
}
.padding(.vertical, 4)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
@@ -77,183 +94,65 @@ struct EditDemoView: View {
.padding(.bottom, 24)
}
private var topBar: some View {
HStack {
Text("OSG")
.font(.system(size: 17, weight: .heavy, design: .rounded))
.foregroundStyle(palette.accent)
Spacer(minLength: 0)
Image(systemName: "xmark")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.frame(width: 44, height: 44)
.background(palette.surfaceElevated.opacity(0.72), in: Circle())
}
// keyboardPanel already contributes 8pt; add the nested 4pt so the
// effective top-bar inset matches the normal voice surface's 12pt.
.padding(.horizontal, Spacing.xs)
}
// Opening frame: idle mic + "" hint.
private var hintBody: some View {
VStack(spacing: Spacing.sm) {
Spacer(minLength: 0)
Text("长按可编辑上一条")
.font(TypeStyle.footnote)
.foregroundStyle(palette.accent)
ZStack {
Circle().fill(palette.accent)
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
.frame(width: 64, height: 64)
.shadow(color: palette.accentGlow, radius: 12)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var pages: some View {
EditTextPager(
originalTitle: "原文",
originalText: Self.originalText,
editedTitle: "编辑后",
editedText: editSession.review?.resultText,
selectedPage: $selectedPage
)
}
private var statusLine: some View {
Text(statusText)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.frame(maxWidth: .infinity)
}
private var pageIndicator: some View {
HStack(spacing: 5) {
Circle()
.fill(selectedPage != 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
Circle()
.fill(selectedPage == 1 ? palette.textPrimary : palette.textTertiary.opacity(0.45))
.frame(width: 5, height: 5)
}
.opacity(editSession.review == nil ? 0 : 1)
}
private var primaryRow: some View {
HStack(spacing: Spacing.sm) {
helperText(leftHelper)
ZStack {
Capsule().fill(palette.accent)
primaryIcon
}
.frame(width: 150, height: 50)
helperText(rightHelper)
}
}
private func helperText(_ value: String) -> some View {
Text(value)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary.opacity(0.55))
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var primaryIcon: some View {
switch editSession {
case .processing, .applying, .appending:
ProgressView().tint(.white)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
case .listening:
VStack(spacing: 0) {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 10, weight: .semibold, design: .rounded))
.monospacedDigit()
Image(systemName: "mic.fill")
.font(.system(size: 16, weight: .semibold))
}
.foregroundStyle(.white)
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
}
}
// MARK: - Copy (mirrors ExtL10n zh keyboard.edit.*)
private var statusText: String {
switch editSession {
case .listening: return "正在聆听编辑指令"
case .processing: return "正在编辑…"
case .review: return "左右滑动对比原文和结果"
case .applying, .appending: return "正在应用编辑…"
default: return ""
}
}
private var leftHelper: String {
editSession.review == nil ? "说话编辑文字" : "左右滑动对比"
}
private var rightHelper: String {
editSession.review != nil ? "点击应用编辑" : "点击完成编辑"
}
private func formatRemaining(_ seconds: Int) -> String {
"\(seconds / 60):\(String(format: "%02d", seconds % 60))"
}
// MARK: - Scripted timeline
// MARK: - Timeline
private func runTimeline() async {
let src = source
let review = EditReview(source: src, resultText: Self.editedText, utteranceID: UUID())
let review = EditReview(
source: src,
resultText: Self.editedText,
utteranceID: UUID()
)
try? await sleep(1.3) // idle hint
state.editCanReplaceOriginal = true
state.layoutWidth = 390
state.micVoiceAvailability = .ready
state.closeEditMode = {}
state.confirmEditResult = {}
state.stopEditListening = {}
state.beginEditLastInput = {}
state.openSettings = {}
try? await sleep(1.4) // hint hold
withAnimation(.easeInOut(duration: 0.28)) {
scene = .editing
state.editSession = .listening(src)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.listening")
state.level = 0.45
state.utteranceRemainingSeconds = 59
}
for _ in 0..<4 {
try? await sleep(0.4)
state.level = Double.random(in: 0.25...0.85)
state.utteranceRemainingSeconds = max(0, state.utteranceRemainingSeconds - 1)
}
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .processing(src)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.processing")
}
try? await sleep(1.5)
withAnimation(.easeInOut(duration: 0.25)) {
showHint = false
editSession = .listening(src)
state.editSession = .review(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.review")
}
// Tick the utterance countdown while listening.
for _ in 0..<3 {
try? await sleep(0.5)
remainingSeconds -= 1
}
withAnimation(.easeInOut(duration: 0.2)) { editSession = .processing(src) }
try? await sleep(1.3)
withAnimation(.easeInOut(duration: 0.25)) {
editSession = .review(review)
selectedPage = 0
}
try? await sleep(1.1)
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
selectedPage = 1
}
try? await sleep(1.6)
withAnimation(.easeInOut(duration: 0.2)) { editSession = .applying(review) }
try? await sleep(0.9)
// Hold long enough for auto-scroll to+ a beat of reading.
try? await sleep(3.2)
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
}
// Stay on applying so the last captured frames are still the real UI.
try? await sleep(2.0)
try? await Task.sleep(nanoseconds: 60_000_000_000)
}
/// Slow-mo for screenshot-sequence recording (~2× wall clock).
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
try await Task.sleep(nanoseconds: UInt64(seconds * 2.0 * 1_000_000_000))
}
}
#endif
+2 -3
View File
@@ -27,7 +27,6 @@ struct HistoryView: View {
}()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
@@ -39,7 +38,8 @@ struct HistoryView: View {
}
.background(palette.background)
.navigationTitle("history.title")
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar {
if !store.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
@@ -82,7 +82,6 @@ struct HistoryView: View {
Text("history.clearDay.message")
}
}
}
// MARK: - List
+214 -97
View File
@@ -1,29 +1,32 @@
// HomeView.swift
// OSGKeyboard · Main App
//
// Minimal home: logo, status capsule, flow hints, inline preview field.
//
// v0.2.0: removed the on-device model warm-up / download state machine
// (Qwen3 CoreML is gone). The local engine uses iOS 26 `SpeechAnalyzer`
// which is always ready, so the previous "model warming / download"
// capsule states collapse into a single "ready" line.
// Home: logo, flow hints, usage stats, history + dictionary entry card,
// then engine / session status at the scroll bottom. History/dictionary
// open via push (system back) rather than bottom-tab destinations.
import SwiftUI
import OSGKeyboardShared
import UIKit
private enum HomeRoute: Hashable {
case history
case dictionary
}
struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
@EnvironmentObject private var flowManager: FlowSessionManager
@FocusState private var previewFocused: Bool
@State private var previewText = ""
@State private var keyboardHintDismissed = HomeGuideState.isKeyboardHintDismissed
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath()
@State private var dictionaryPreviewEntries: [PersonalDictionary.Entry] = []
private var usesWideLayout: Bool {
horizontalSizeClass == .regular
@@ -61,6 +64,7 @@ struct HomeView: View {
}
var body: some View {
NavigationStack(path: $path) {
Group {
if usesWideLayout {
wideBody
@@ -68,19 +72,35 @@ struct HomeView: View {
phoneBody
}
}
.toolbar(path.isEmpty ? .hidden : .automatic, for: .navigationBar)
.navigationDestination(for: HomeRoute.self) { route in
switch route {
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
}
}
}
.onAppear {
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
refreshPermissionStatuses()
refreshDictionaryPreview()
}
.onChange(of: previewFocused) { _, focused in
guard focused else { return }
Task { await flowManager.refreshForInlineKeyboardFocus() }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
refreshDictionaryPreview()
}
.onChange(of: path.count) { _, count in
guard count == 0 else { return }
refreshDictionaryPreview()
}
}
@@ -89,26 +109,17 @@ struct HomeView: View {
private var phoneBody: some View {
GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top
// iPhone SE
// tab
let isCompact = geo.size.height < 700
// logo
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
// /
let previewMinHeight: CGFloat = {
if showsFlowSessionExtras {
return isCompact ? 44 : 88
}
return isCompact ? 72 : 160
}()
ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight)
.ignoresSafeArea(edges: .top)
.allowsHitTesting(false)
ScrollView {
VStack(spacing: 0) {
logoHeader(compact: isCompact)
.padding(.top, logoTopPadding)
@@ -124,74 +135,57 @@ struct HomeView: View {
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
// safeAreaInset
// tab dock
previewField(minHeight: previewMinHeight)
homeLibrarySection
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.xl)
// Scrolls with the page (not pinned); clearance comes from
// `tabBarScrollBottomPadding` so the dock never covers it.
scrollStatusFooter
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.layoutPriority(-1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.safeAreaInset(edge: .bottom, spacing: 0) {
phoneStatusFooter
.frame(maxWidth: .infinity)
.tabBarScrollBottomPadding()
}
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
}
/// Engine + Flow bottom inset tab
private var phoneStatusFooter: some View {
HStack(spacing: Spacing.sm) {
/// Engine + Flow status last content in the scroll stack.
private var scrollStatusFooter: some View {
VStack(spacing: Spacing.xs) {
engineStatusLine
flowStatusFooter
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.sm)
.background(palette.background.opacity(0.96))
.multilineTextAlignment(.center)
}
// MARK: - Wide layout (iPad / regular width)
private var wideBody: some View {
VStack(spacing: 0) {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader
// On iPad / regular width the keyboard-setup hint (and any
// other flow-session extras) used to render below
// `HomeUsageStatsSection`, burying the most actionable guidance
// beneath the stats cards. Match the phone layout's ordering:
// hero header hint stats preview, so the hint sits at
// the top of the page and is the first thing a user notices.
if showsFlowSessionExtras {
flowSessionExtras
}
HomeUsageStatsSection(layout: .split)
widePreviewStage
homeLibrarySection
scrollStatusFooter
.frame(maxWidth: .infinity)
}
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm)
.padding(.bottom, Spacing.md)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.tabBarScrollBottomPadding()
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
private var wideHeroHeader: some View {
@@ -209,17 +203,145 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
private var widePreviewStage: some View {
WideCard(padding: Spacing.md, cornerRadius: Radius.large) {
previewFieldContent
.frame(
maxWidth: .infinity,
minHeight: WideLayoutMetrics.dictationCanvasMinHeight,
maxHeight: .infinity,
alignment: .topLeading
)
// MARK: - History / dictionary cards
/// Two independent cards; each header mirrors the stats tiles (accent icon
/// + small uppercase label) and the body grows with its rows.
private var homeLibrarySection: some View {
VStack(spacing: Spacing.md) {
historyCard
dictionaryCard
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var historyCard: some View {
let entries = Array(speechHistory.entries.prefix(Self.libraryPreviewLimit))
return homeLibraryCard(
titleKey: "history.title",
systemImage: "clock.arrow.circlepath",
route: .history
) {
if entries.isEmpty {
libraryEmptyLine("home.card.history.empty")
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
HStack(alignment: .firstTextBaseline, spacing: Spacing.sm) {
Text(entry.text)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
Spacer(minLength: Spacing.xs)
Text(Self.previewTimeFormatter.string(from: entry.createdAt))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
}
}
}
}
}
private var dictionaryCard: some View {
homeLibraryCard(
titleKey: "settings.personalDictionary.title",
systemImage: "character.book.closed",
route: .dictionary
) {
if dictionaryPreviewEntries.isEmpty {
libraryEmptyLine("home.card.dictionary.empty")
} else {
VStack(spacing: 0) {
ForEach(Array(dictionaryPreviewEntries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { libraryRowDivider }
VStack(alignment: .leading, spacing: 2) {
Text(entry.term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text(dictionaryDetailLine(for: entry))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.sm)
}
}
}
}
}
/// Card shell: accent icon + label top-left, chevron trailing, custom body.
private func homeLibraryCard<Content: View>(
titleKey: LocalizedStringKey,
systemImage: String,
route: HomeRoute,
@ViewBuilder content: () -> Content
) -> some View {
Button {
path.append(route)
} label: {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: Spacing.xs) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
.symbolRenderingMode(.hierarchical)
Text(titleKey)
.font(TypeStyle.caption2)
.tracking(0.6)
.textCase(.uppercase)
.foregroundStyle(palette.textTertiary)
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
content()
}
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.surfaceCard()
.accessibilityElement(children: .combine)
}
private var libraryRowDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
}
private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View {
Text(key)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.xs)
}
/// Source · uses · aliases same secondary line as the dictionary page rows.
private func dictionaryDetailLine(for entry: PersonalDictionary.Entry) -> String {
var parts = [SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)]
if entry.usageCount > 1 {
let format = AppL10n.string(
"settings.personalDictionary.usageCount",
language: config.uiLanguage
)
parts.append(String(format: format, entry.usageCount))
}
if !entry.aliases.isEmpty {
parts.append(entry.aliases.joined(separator: " / "))
}
return parts.joined(separator: " · ")
}
private func refreshPermissionStatuses() {
@@ -227,6 +349,18 @@ struct HomeView: View {
speechStatus = AppPermissions.speechStatus
}
private func refreshDictionaryPreview() {
dictionaryPreviewEntries = AppGroupStore().personalDictionary.entries
.sorted { lhs, rhs in
if lhs.updatedAt != rhs.updatedAt {
return lhs.updatedAt > rhs.updatedAt
}
return lhs.usageCount > rhs.usageCount
}
.prefix(Self.libraryPreviewLimit)
.map { $0 }
}
private func handlePermissionGuidanceAction() {
if AppPermissions.canRequestPermissionsInApp {
Task {
@@ -292,7 +426,7 @@ struct HomeView: View {
.frame(width: 6, height: 6)
if needsAPIKeySetup {
// API Key / /
// API Key
Text("home.flow.notReady")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
@@ -359,7 +493,6 @@ struct HomeView: View {
.padding(.leading, Spacing.xs)
}
}
.fixedSize(horizontal: true, vertical: false)
.animation(Motion.soft, value: flowManager.isActive)
}
@@ -461,7 +594,7 @@ struct HomeView: View {
}
/// Single source of truth for the logo status capsule. The local
/// engine is always "ready" in v0.2.0 (iOS `SpeechAnalyzer` ships
/// engine is always "ready" because iOS `SpeechAnalyzer` ships
/// with the OS), so the previous downloading / warming / failed
/// states collapse into the cloud-engine branch.
private var flowCapsuleStatusMessage: String {
@@ -484,32 +617,6 @@ struct HomeView: View {
return AppL10n.string("home.flow.inactive")
}
// MARK: - Preview field
private func previewField(minHeight: CGFloat) -> some View {
previewFieldContent
.frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.onTapGesture {
previewFocused = true
}
}
private var previewFieldContent: some View {
TextField("home.preview.placeholder", text: $previewText, axis: .vertical)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
}
private var engineStatusLine: some View {
Text(
EngineServiceLabel.summary(
@@ -525,6 +632,16 @@ struct HomeView: View {
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
/// Rows shown inside the history / dictionary preview cards.
private static let libraryPreviewLimit = 3
private static let previewTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
}
// MARK: - Home guidance persistence
@@ -10,7 +10,7 @@
import SwiftUI
import OSGKeyboardShared
// MARK: - Local models group (v0.2.0)
// MARK: - Local models group
struct LocalModelsGroup: View {
@Environment(\.themePalette) private var palette: ThemePalette
+55 -13
View File
@@ -18,6 +18,7 @@ struct MainAppRoot: View {
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
@StateObject private var flowManager = FlowSessionManager()
@State private var clmWarmupTask: Task<Void, Never>?
@State private var rimeStartupTask: Task<Void, Never>?
var body: some View {
Group {
@@ -67,13 +68,19 @@ struct MainAppRoot: View {
if config.hasCompletedOnboarding {
// Rime deployment is host-only and idempotent. Run it
// immediately when missing so returning users never have to
// wait for an opportunistic background warmup.
RimeDeploymentController.shared.deployNow(reason: "MainAppRoot.onAppear")
// Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press.
flowManager.activateOnForeground(reason: "MainAppRoot.onAppear")
scheduleCLMWarmup(reason: "MainAppRoot.onAppear")
// wait for an opportunistic background warmup. The startup
// scheduler yields the first frame before beginning deployment.
if scenePhase == .active {
activateForegroundServices(reason: "MainAppRoot.onAppear")
AIHintRefreshService.refreshIfNeeded(reason: "MainAppRoot.onAppear")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
} else {
OSGDiag.log(
"MainAppRoot.onAppear defer foreground services scene="
+ "\(String(describing: scenePhase))",
category: "flow"
)
}
} else {
OSGDiag.log(
"MainAppRoot.onAppear skip Flow/CLM/Rime (onboarding incomplete)",
@@ -86,29 +93,30 @@ struct MainAppRoot: View {
}
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done {
flowManager.activateOnForeground(reason: "onboardingCompleted")
// Deploy now rather than via warmup: the user just finished
// setup, is still in the app, and has not started using the
// keyboard yet so there is nothing to race for memory. This
// also covers users who skipped the keyboard page entirely.
RimeDeploymentController.shared.deployNow(reason: "onboardingCompleted")
scheduleCLMWarmup(reason: "onboardingCompleted")
if scenePhase == .active {
activateForegroundServices(reason: "onboardingCompleted")
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
guard phase == .active else {
clmWarmupTask?.cancel()
clmWarmupTask = nil
rimeStartupTask?.cancel()
rimeStartupTask = nil
FlowSessionBridge.setHostHeavy(false)
return
}
if config.hasCompletedOnboarding {
RimeDeploymentController.shared.deployNow(reason: "scenePhase.active")
flowManager.activateOnForeground(reason: "scenePhase.active")
// Retry deferred CLM after a jetsam-prone launch.
scheduleCLMWarmup(reason: "scenePhase.active.retry")
activateForegroundServices(reason: "scenePhase.active")
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
Task {
@@ -117,6 +125,40 @@ struct MainAppRoot: View {
}
}
/// Starts foreground-only services once per active transition. Rime yields
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
private func activateForegroundServices(reason: String) {
scheduleRimeDeployment(reason: reason)
// Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press.
flowManager.activateOnForeground(reason: reason)
// Retry deferred CLM after a jetsam-prone launch.
scheduleCLMWarmup(reason: reason)
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
/// Rime remains startup-owned, but a short delay keeps its CPU and file I/O
/// away from SwiftUI's first-frame layout on installs and version updates.
private func scheduleRimeDeployment(reason: String) {
rimeStartupTask?.cancel()
guard !RimeResourceInstaller.isReady else {
RimeDeploymentController.shared.refreshStatus()
rimeStartupTask = nil
return
}
OSGDiag.log(
"rime startup scheduled reason=\(reason) delay=500ms \(OSGDiag.memoryTag())",
category: "flow"
)
rimeStartupTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled, scenePhase == .active else { return }
RimeDeploymentController.shared.deployNow(reason: reason)
rimeStartupTask = nil
}
}
@ViewBuilder
private var mainContent: some View {
if config.hasCompletedOnboarding {
-4
View File
@@ -14,10 +14,6 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .styles:
PolishStylesView()
case .settings:
+166
View File
@@ -0,0 +1,166 @@
// NotesHostDemoView.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Minimal Notes / Messages-like host for What's New recording. Only presents a
// text field the **real** keyboard extension paints over it (Approach A).
// Launch with `--whats-new-host` (+ optional `--whats-new-scenario=` / `--whats-new-lang=`).
#if DEBUG
import SwiftUI
import UIKit
import OSGKeyboardShared
struct NotesHostDemoView: View {
let scenario: WhatsNewDemoScenario
let seedText: String
let language: WhatsNewDemoScenario.Language
private var title: String {
switch (scenario, language) {
case (.ai, .en): return "Messages"
case (.ai, .zh): return "信息"
case (.edit, .en), (.clipboard, .en): return "Notes"
case (.edit, .zh), (.clipboard, .zh): return "备忘录"
}
}
var body: some View {
ZStack {
Color(uiColor: .systemGroupedBackground)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 13, weight: .regular))
.foregroundStyle(Color(uiColor: .secondaryLabel))
.padding(.horizontal, 4)
if scenario == .ai {
ChatHostTextView(text: seedText)
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
} else {
NotesHostTextView(text: seedText)
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
}
.padding(.horizontal, 18)
.padding(.top, 56)
.padding(.bottom, 12)
}
.preferredColorScheme(.light)
.environment(
\.locale,
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
)
.task {
// Refresh TTL while armed; stop once the extension consumes / plays.
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
for _ in 0..<25 {
try? await Task.sleep(nanoseconds: 700_000_000)
if WhatsNewDemoScenario.isPlaying() { break }
guard WhatsNewDemoScenario.peek() != nil else { break }
WhatsNewDemoScenario.arm(scenario, seedText: seedText, language: language)
}
}
}
}
/// UITextView wrapper that becomes first responder so the system presents
/// the real custom keyboard extension.
private struct NotesHostTextView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.backgroundColor = .clear
view.font = .systemFont(ofSize: 20)
view.textColor = .label
view.text = text
view.isEditable = true
view.isScrollEnabled = true
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
view.returnKeyType = .default
view.delegate = context.coordinator
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
view.becomeFirstResponder()
}
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text, !context.coordinator.userEdited {
uiView.text = text
}
if !uiView.isFirstResponder {
DispatchQueue.main.async {
_ = uiView.becomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, UITextViewDelegate {
var userEdited = false
func textViewDidChange(_ textView: UITextView) {
userEdited = true
}
}
}
/// Messaging-style composer: Return key is **Send** so AI insert send is valid.
private struct ChatHostTextView: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.backgroundColor = .clear
view.font = .systemFont(ofSize: 17)
view.textColor = .label
view.text = text
view.isEditable = true
view.isScrollEnabled = true
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
view.textContainer.lineFragmentPadding = 0
view.returnKeyType = .send
view.enablesReturnKeyAutomatically = true
view.delegate = context.coordinator
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
view.becomeFirstResponder()
}
return view
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.text != text, !context.coordinator.userEdited {
uiView.text = text
}
if !uiView.isFirstResponder {
DispatchQueue.main.async {
_ = uiView.becomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, UITextViewDelegate {
var userEdited = false
func textViewDidChange(_ textView: UITextView) {
userEdited = true
}
}
}
#endif
+4 -4
View File
@@ -32,7 +32,7 @@ struct OnboardingView: View {
@State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
@State private var keyboardReady = KeyboardSetupBridge.isReadyForOnboardingSkip
// v0.2.0: no on-device model downloads remain, so the API setup
// No on-device model downloads remain, so the API setup
// page no longer needs a ModelManager / pendingDownload binding.
private var currentPage: OnboardingPage {
@@ -90,7 +90,7 @@ struct OnboardingView: View {
applyOnboardingDefaultsIfNeeded()
refreshPermissionStatuses()
snapToVisiblePageIfNeeded()
// v0.2.0: no on-device ASR weights to warm up iOS
// No on-device ASR weights need warming iOS
// `SpeechAnalyzer` ships with iOS 26 and is always ready.
}
.onChange(of: scenePhase) { _, phase in
@@ -111,13 +111,13 @@ struct OnboardingView: View {
private func applyOnboardingDefaultsIfNeeded() {
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
// First-time users with no API key: default to local for a faster path.
// v0.2.0: iOS SpeechAnalyzer is the only on-device ASR path.
// iOS SpeechAnalyzer is the only on-device ASR path.
if config.apiKey.isEmpty, config.engineMode == "cloud" {
config.engineMode = "local"
}
}
/// v0.2.0: with iOS `SpeechAnalyzer` as the only local backend,
/// With iOS `SpeechAnalyzer` as the only local backend,
/// the "local engine ready" check is always true there is nothing
/// for the user to download. Kept as a derived property so the
/// existing call sites (which feed the Done button state) compile
@@ -25,7 +25,6 @@ struct PersonalDictionaryView: View {
private let aliasGenerator = DictionaryAliasGenerator()
var body: some View {
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
@@ -37,7 +36,8 @@ struct PersonalDictionaryView: View {
}
.background(palette.background)
.navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.large)
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar {
if !dictionary.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
@@ -71,7 +71,6 @@ struct PersonalDictionaryView: View {
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
}
}
}
.sheet(isPresented: $showEntrySheet) {
PersonalDictionaryEntrySheet(
initialTerm: editingEntry?.term ?? "",
@@ -1,10 +0,0 @@
// PreviewASRController.swift
// OSGKeyboard · Main App
//
// Legacy name kept for existing call sites and tests. The implementation
// lives in `OSGKeyboardShared` as `LiveDictationController`.
import OSGKeyboardShared
import OSGKeyboardHostSupport
typealias PreviewASRController = LiveDictationController
+25 -5
View File
@@ -122,18 +122,26 @@ struct SettingsICloudSyncRow: View {
do {
try await CloudSyncContext.shared.dictionarySyncService.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
CloudSyncContext.shared.settingsSyncService.disableSync()
do {
try CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = localizedDictionarySyncError(error)
} catch let rollbackError as SettingsCloudSyncError {
reloadFromStore()
syncErrorMessage = localizedSyncError(rollbackError)
} catch {
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
return
}
reloadFromStore()
} catch let error as SettingsCloudSyncError {
isEnabled = false
reloadFromStore()
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
@@ -141,9 +149,19 @@ struct SettingsICloudSyncRow: View {
}
private func disableSync() {
CloudSyncContext.shared.settingsSyncService.disableSync()
isEnabled = false
syncErrorMessage = nil
isApplyingToggle = true
do {
try CloudSyncContext.shared.settingsSyncService.disableSync()
reloadFromStore()
} catch let error as SettingsCloudSyncError {
reloadFromStore()
syncErrorMessage = localizedSyncError(error)
} catch {
reloadFromStore()
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
private func syncNow() {
@@ -171,6 +189,8 @@ struct SettingsICloudSyncRow: View {
switch error {
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
case .credentialMigrationFailed:
return AppL10n.string("settings.appSettings.iCloudSync.error.generic")
}
}
+39 -5
View File
@@ -152,15 +152,21 @@ struct AIResponseLengthPickerRow: View {
}
}
// MARK: - Default input surface toggle
// MARK: - Default input mode picker
struct DefaultTypingInputToggleRow: View {
struct DefaultInputModePickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
@Binding var isOn: Bool
@Binding var selection: DefaultInputMode
private var options: [(id: String, label: String)] {
DefaultInputMode.allCases.map { mode in
(mode.rawValue, AppL10n.string(mode.labelKey, language: config.uiLanguage))
}
}
var body: some View {
Toggle(isOn: $isOn) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(AppL10n.string("settings.typingInput.default.title", language: config.uiLanguage))
.font(TypeStyle.body)
@@ -170,10 +176,38 @@ struct DefaultTypingInputToggleRow: View {
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Menu {
ForEach(options, id: \.id) { option in
Button {
selection = DefaultInputMode(rawValue: option.id) ?? .voice
} label: {
if option.id == selection.rawValue {
Label(option.label, systemImage: "checkmark")
} else {
Text(option.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.tint(palette.accent)
.settingsListRow()
}
private var currentLabel: String {
options.first(where: { $0.id == selection.rawValue })?.label ?? ""
}
}
struct RememberLastSurfaceToggleRow: View {
+114 -25
View File
@@ -125,12 +125,16 @@ struct SettingsModelPickerRow: View {
let title: String
let placeholder: String
@Binding var model: String
let fetchModels: () async throws -> [String]
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let makeFetchModelsRequest: @MainActor () -> ProviderToolRequest<[String]>
@State private var models: [String] = []
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
private let controlHeight: CGFloat = 38
@@ -150,6 +154,11 @@ struct SettingsModelPickerRow: View {
}
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: model) { _, _ in invalidateRequestIfRunning() }
.onDisappear { invalidateRequest() }
}
/// Editable model id + trailing menu chevron in one well (same chrome as
@@ -158,7 +167,7 @@ struct SettingsModelPickerRow: View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
return ZStack(alignment: .trailing) {
TextField(placeholder, text: $model)
TextField(placeholder, text: editableModelBinding)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
@@ -177,6 +186,7 @@ struct SettingsModelPickerRow: View {
} else {
ForEach(models, id: \.self) { modelId in
Button {
invalidateRequest()
model = modelId
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
@@ -203,7 +213,7 @@ struct SettingsModelPickerRow: View {
private var refreshButton: some View {
Button {
Task { await runFetchModels() }
runFetchModels()
} label: {
Group {
if isRunning {
@@ -227,38 +237,86 @@ struct SettingsModelPickerRow: View {
.accessibilityLabel(AppL10n.string("settings.provider.fetchModels"))
}
private var editableModelBinding: Binding<String> {
Binding(
get: { model },
set: { newValue in
invalidateRequest()
model = newValue
}
)
}
@MainActor
private func runFetchModels() async {
private func runFetchModels() {
let request = makeFetchModelsRequest()
let currentModel = model
let runningMessage = AppL10n.string("settings.provider.loadingModels")
let emptyMessage = SharedL10n.string("providerTools.error.empty")
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: AppL10n.string("settings.provider.loadingModels"),
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runFetchModels(
runningMessage: runningMessage,
loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty"),
currentModel: model,
fetchModels: fetchModels
emptyMessage: emptyMessage,
currentModel: currentModel,
fetchModels: request.operation
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state, let selectedModel):
models = state.models
message = state.message
failed = state.failed
if let selectedModel {
model = selectedModel
}
}
}
)
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
@MainActor
private func invalidateRequestIfRunning() {
guard requestCoordinator.isRunning else { return }
invalidateRequest()
}
}
// MARK: - Connection validate only
struct SettingsProviderToolsRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
let validate: () async throws -> Void
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let modelIdentity: String
let makeValidateRequest: @MainActor () -> ProviderToolRequest<Void>
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
@@ -280,7 +338,7 @@ struct SettingsProviderToolsRow: View {
Spacer(minLength: 0)
Button {
Task { await runValidate() }
runValidate()
} label: {
Text(AppL10n.string("settings.provider.validate"))
.font(TypeStyle.body)
@@ -297,20 +355,51 @@ struct SettingsProviderToolsRow: View {
.disabled(isRunning)
}
.settingsListRow()
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: modelIdentity) { _, _ in invalidateRequest() }
.onDisappear { invalidateRequest() }
}
@MainActor
private func runValidate() async {
private func runValidate() {
let request = makeValidateRequest()
let runningMessage = AppL10n.string("api.test.running")
let successMessage = AppL10n.string("api.test.success")
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runValidate(
runningMessage: AppL10n.string("api.test.running"),
successMessage: AppL10n.string("api.test.success"),
validate: validate
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runValidate(
runningMessage: runningMessage,
successMessage: successMessage,
validate: request.operation
)
message = outcome.message
failed = outcome.failed
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state):
message = state.message
failed = state.failed
}
}
)
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
}
+63 -2
View File
@@ -220,8 +220,8 @@ struct GeneralSettingsView: View {
CardSection("settings.general.keyboard.title") {
VStack(spacing: 0) {
DefaultTypingInputToggleRow(
isOn: $typingConfiguration.defaultToTyping
DefaultInputModePickerRow(
selection: $typingConfiguration.defaultInputMode
)
Divider().background(palette.divider)
@@ -311,6 +311,8 @@ struct AIAgentSettingsView: View {
struct ClipboardSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
@ObservedObject private var history = ClipboardHistoryStore.shared
@State private var showClearConfirmation = false
var body: some View {
ScrollView {
@@ -363,6 +365,20 @@ struct ClipboardSettingsView: View {
Divider().background(palette.divider)
Button {
AppPermissions.requestPasteAccess()
} label: {
SettingsNavigationRow(
titleText: AppL10n.string(
"settings.clipboard.paste.request",
language: config.uiLanguage
)
)
}
.buttonStyle(.plain)
Divider().background(palette.divider)
Button {
AppPermissions.openSystemSettings()
} label: {
@@ -377,12 +393,57 @@ struct ClipboardSettingsView: View {
}
.surfaceCard()
}
CardSection("settings.clipboard.storage.section") {
VStack(spacing: 0) {
Text("settings.clipboard.storage.body")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider().background(palette.divider)
Button(role: .destructive) {
showClearConfirmation = true
} label: {
HStack {
Text("settings.clipboard.clear.button")
Spacer()
Image(systemName: "trash")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(history.entries.isEmpty)
.opacity(history.entries.isEmpty ? 0.45 : 1)
}
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.confirmationDialog(
"settings.clipboard.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("settings.clipboard.clear.confirm", role: .destructive) {
history.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("settings.clipboard.clear.message")
}
.onAppear {
history.reload()
}
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
if !enabled {
config.clipboardCandidateBarEnabled = false
+3 -12
View File
@@ -6,18 +6,9 @@
// default) or one of the 10 target languages, all from a single
// `Menu`.
//
// v0.2.1 follow-up: row is rendered through an `isVisible` parameter
// so callers (`SettingsView`, `OnboardingView`) can drop the row
// entirely when the engine can't run the cloud translate-and-polish
// step (`ProviderConfig.isTranslationRowVisible`). The "needs cloud"
// inline hint was deleted along with the previous Bool toggle the
// user only sees the row when the engine can act on the choice.
//
// v0.2.1 final review: both engines now run the translate-and-polish
// step (the local engine routes through DeepSeek via
// `ProviderConfig.localModeProviderId`), so the row title changed
// from "Translation" to "Polish then translate" to match the new
// always-on translation contract.
// `isVisible` keeps conditional rendering under caller control. Current
// settings expose the row for both local and cloud ASR modes, and both
// use the user's selected polish provider.
//
// Mapping to persisted state:
// "" translationTargetLocaleId = "off"
+22 -11
View File
@@ -101,7 +101,7 @@
"settings.appearance.light" = "Light";
"settings.appearance.dark" = "Dark";
"settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared. Clipboard history is kept.";
"settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Speech Transcription & Polish";
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
@@ -142,8 +142,8 @@
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.apiKey" = "API Key";
"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key auth";
"settings.asr.volcengine.apiKeyMode.subtitle" = "Turn on for the new Volcengine console. Keep off if you already use APP ID + Access Token.";
"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key";
"settings.asr.volcengine.apiKeyMode.subtitle" = "API Key for the new console; turn off for APP ID + Token.";
"settings.asr.volcengine.note.appToken" = "Legacy console: enter APP ID and Access Token. Secret Key is not required. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"settings.asr.volcengine.note.apiKey" = "New console: enter only the API Key. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"provider.openai" = "OpenAI";
@@ -205,17 +205,27 @@
"settings.clipboard.subtitle.on" = "On";
"settings.clipboard.subtitle.off" = "Off";
"settings.clipboard.history.title" = "History";
"settings.clipboard.history.footer" = "Keeps the latest 15 plain-text copies on this device.";
"settings.clipboard.history.footer" = "Off by default. Captures text copied on this device or through Universal Clipboard and keeps up to 15 items in this devices App Group. AI mode can suggest clipboard-related prompts for about 30 seconds after a copy.";
"settings.clipboard.candidate.title" = "Suggestion strip";
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
"settings.clipboard.paste.section" = "System access";
"settings.clipboard.paste.body" = "Set Paste from Other Apps to Allow to stop paste prompts. If the option is missing, copy text and tap the keyboard suggestion once first.";
"settings.clipboard.paste.body" = "Copy some text, tap Request Paste Access, and allow access. iOS will then create the Paste from Other Apps setting, where you can select Allow.";
"settings.clipboard.paste.request" = "Request Paste Access";
"settings.clipboard.paste.open" = "Open iOS Settings";
"settings.clipboard.storage.section" = "Local history";
"settings.clipboard.storage.body" = "Turning History off stops capture and the suggestion strip but keeps saved items. Clipboard history does not sync through iCloud and is never sent to AI automatically. After you insert it, active polish may include it as context for your configured provider. Sensitive filtering is conservative and cannot detect every password.";
"settings.clipboard.clear.button" = "Clear clipboard history";
"settings.clipboard.clear.title" = "Clear clipboard history?";
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
"settings.clipboard.clear.confirm" = "Clear history";
"settings.typingInput.title" = "Text Input";
"settings.typingInput.default.title" = "Default to Text Input";
"settings.typingInput.default.description" = "Open the text input keyboard instead of voice input by default";
"settings.typingInput.default.title" = "Default Input";
"settings.typingInput.default.description" = "Used when the keyboard opens";
"settings.typingInput.default.mode.voice" = "Voice";
"settings.typingInput.default.mode.pinyin" = "Pinyin";
"settings.typingInput.default.mode.english" = "English";
"settings.typingInput.rememberLast.title" = "Remember Last Choice";
"settings.typingInput.rememberLast.description" = "Reopen on the voice or text surface you left last time";
"settings.typingInput.rememberLast.description" = "Restore last surface on reopen";
"settings.typingInput.schema.section" = "Input Method";
"settings.typingInput.schema.picker" = "Pinyin Scheme";
"typing.schema.fullPinyin" = "Full Pinyin";
@@ -240,7 +250,7 @@
"settings.keyboardHaptic.off" = "Off";
"settings.keyboardHaptic.light" = "Light";
"settings.keyboardHaptic.strong" = "Strong";
"settings.cursorDragNavigation.title" = "Drag beside mic to move cursor";
"settings.cursorDragNavigation.title" = "Drag empty area to move cursor";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -343,8 +353,6 @@
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
"keyboard.placeholder.cloudBadge" = "Cloud";
"keyboard.rec" = "REC";
"keyboard.space" = "Space";
"keyboard.denied.mic" = "Mic denied";
@@ -399,6 +407,8 @@
"home.flow.startShort" = "Start";
"home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…";
"home.card.history.empty" = "No voice transcripts yet";
"home.card.dictionary.empty" = "No words yet";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.mode.cloud" = "Cloud";
"home.wide.mode.local" = "On-device";
@@ -578,3 +588,4 @@
"flow.warning.polishDegradedQuality" = "Polish output failed validation; inserted a conservative version.";
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
+25 -14
View File
@@ -101,7 +101,7 @@
"settings.appearance.light" = "浅色";
"settings.appearance.dark" = "深色";
"settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态;剪贴板历史会保留。";
"settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "语音转写与润色";
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
@@ -142,8 +142,8 @@
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
"settings.asr.volcengine.apiKey" = "API Key";
"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key 鉴权";
"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台请打开此开关;已有 AppID + Token 可保持关闭。";
"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key";
"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台用 API Key;旧版请关闭并用 APP ID + Token。";
"settings.asr.volcengine.note.appToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0volc.seedasr.sauc.duration)。";
"settings.asr.volcengine.note.apiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0volc.seedasr.sauc.duration)。";
"provider.openai" = "OpenAI";
@@ -205,17 +205,27 @@
"settings.clipboard.subtitle.on" = "已开启";
"settings.clipboard.subtitle.off" = "已关闭";
"settings.clipboard.history.title" = "历史记录";
"settings.clipboard.history.footer" = "本机保存最近 15 条纯文本。";
"settings.clipboard.history.footer" = "默认关闭。开启后采集本机或通用剪贴板复制的文本,并在本机 App Group 中保存最近 15 条;复制约 30 秒内,AI 模式也可展示剪贴板相关建议。";
"settings.clipboard.candidate.title" = "建议条";
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
"settings.clipboard.paste.section" = "系统授权";
"settings.clipboard.paste.body" = "将「从其他 App 粘贴」设为「允许」,即可不再弹窗。若没有此项,先复制文字并点一次键盘建议条。";
"settings.clipboard.paste.body" = "先复制一段文字,再点「请求粘贴权限」并允许访问;iOS 随后会生成「从其他 App 粘贴」设置项,可将其设为「允许」。";
"settings.clipboard.paste.request" = "请求粘贴权限";
"settings.clipboard.paste.open" = "打开系统设置";
"settings.clipboard.storage.section" = "本机历史";
"settings.clipboard.storage.body" = "关闭「历史记录」只会停止采集并关闭建议条,已有记录仍会保留。剪贴板历史不经 iCloud 同步,也不会自动发送给 AI;插入后若主动使用润色,内容可能作为上下文发送给你配置的服务商。敏感内容过滤采用保守规则,无法识别所有密码。";
"settings.clipboard.clear.button" = "清空剪贴板历史";
"settings.clipboard.clear.title" = "清空剪贴板历史?";
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
"settings.clipboard.clear.confirm" = "清空历史";
"settings.typingInput.title" = "文本输入";
"settings.typingInput.default.title" = "默认进行文字输入";
"settings.typingInput.default.description" = "打开键盘时,默认使用文字输入键盘而非语音输入";
"settings.typingInput.default.title" = "默认输入方式";
"settings.typingInput.default.description" = "打开键盘时使用此方式";
"settings.typingInput.default.mode.voice" = "语音";
"settings.typingInput.default.mode.pinyin" = "拼音";
"settings.typingInput.default.mode.english" = "英文";
"settings.typingInput.rememberLast.title" = "记住上次选择";
"settings.typingInput.rememberLast.description" = "下次打开键盘时,保持你上次离开时的语音或文字输入界面";
"settings.typingInput.rememberLast.description" = "下次打开时恢复上次界面";
"settings.typingInput.schema.section" = "输入方案";
"settings.typingInput.schema.picker" = "拼音方案";
"typing.schema.fullPinyin" = "全拼";
@@ -228,8 +238,8 @@
"settings.typingInput.resources.ready" = "已就绪";
"settings.typingInput.resources.pending" = "待初始化";
"settings.typingInput.resources.redeploy" = "重新部署输入法资源";
"settings.speechRecognition.title" = "语音识别配置";
"settings.textPolish.title" = "文本润色配置";
"settings.speechRecognition.title" = "语音识别";
"settings.textPolish.title" = "文本润色";
"settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.polishPreferences.title" = "润色偏好";
@@ -240,7 +250,7 @@
"settings.keyboardHaptic.off" = "关";
"settings.keyboardHaptic.light" = "轻";
"settings.keyboardHaptic.strong" = "强";
"settings.cursorDragNavigation.title" = "麦克风旁拖动移动光标";
"settings.cursorDragNavigation.title" = "触摸空白区域移动光标";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -342,15 +352,13 @@
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";
"keyboard.placeholder.cloudBadge" = "云端";
"keyboard.rec" = "REC";
"keyboard.space" = "空格";
"keyboard.denied.mic" = "麦克风被拒绝";
"keyboard.denied.speech" = "语音识别被拒绝";
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.pressToTalkA11y" = "按说话";
"keyboard.tapToTalkA11y" = "按说话";
/* Mode chip labels */
"mode.off" = "关闭";
@@ -398,6 +406,8 @@
"home.flow.startShort" = "开启";
"home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘";
"home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端";
"home.wide.mode.local" = "本机";
@@ -577,3 +587,4 @@
"flow.warning.polishDegradedQuality" = "本次润色结果未通过校验,已插入保守处理版本。";
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
+104 -50
View File
@@ -49,19 +49,21 @@ public final class KeyboardViewController: UIInputViewController {
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
private var systemEncapsulatedHeight: CGFloat = 228
/// Presentation height priming is only valid during the slide-in. After
/// `viewDidAppear` we must keep the constraint at `target` re-applying
/// the offset (or letting a paste alert interrupt the appear sequence)
/// makes the slot land at `target + encapsulated` and floats the chrome.
/// The system owns the input view's height during the slide-in via a
/// required `UIView-Encapsulated-Layout-Height` constraint, which it walks
/// from the full screen height down to the keyboard slot. Our own
/// constraint only has to hold `target` and stay out of that transition.
private enum HeightPresentationPhase {
case idle
case priming
case presented
}
private var heightPhase: HeightPresentationPhase = .idle
/// Last logged layout snapshot, so `viewDidLayoutSubviews` only reports
/// changes instead of every pass.
private var lastLoggedLayoutSnapshot: String?
private var cancellables = Set<AnyCancellable>()
private var editHintScheduler: EditHintScheduler!
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
private var lastInputEditCoordinator: LastInputEditCoordinator!
@@ -168,6 +170,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
clipboardCapture?.keyboardWillDisappear()
// Presentation-scoped hints must never survive a reused extension
// controller, including an active Flow handoff.
editHintScheduler?.invalidate()
OSGDiag.log(
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
@@ -181,6 +186,10 @@ public final class KeyboardViewController: UIInputViewController {
TypingInputConfiguration.persistLastSurface(
preserve ? .voice : state.surface
)
// Remember pinyin/English with the surface so "remember last" restores both.
if state.surface == .typing {
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
}
if state.surface == .ai {
// AI context never survives a keyboard presentation, but the
// selected surface itself is restored on the next open.
@@ -210,7 +219,6 @@ public final class KeyboardViewController: UIInputViewController {
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
// Refresh only Flow/config state; edit targets come from verified OSG insertions.
flowCoordinator.refreshSessionState()
flowCoordinator.startSessionMonitor()
@@ -235,20 +243,18 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
if heightPhase == .presented {
// Spurious re-appear while already on screen (e.g. system alert
// lifecycle noise) never re-run the offset trick.
// One constant, every time: the previous "prime at target system
// encapsulated height" trick read the pre-presentation full-screen
// height (874 pt on an iPhone), clamped to 0, and could not win against
// the system's required constraint anyway.
lockPresentedKeyboardHeight()
} else {
heightPhase = .priming
applyPresentationHeightOffset()
}
OSGDiag.log(
"KVC.viewIsAppearing phase=\(heightPhaseLog) "
+ "height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
logHeightConstraints(tag: "viewIsAppearing")
}
public override func viewDidAppear(_ animated: Bool) {
@@ -266,6 +272,26 @@ public final class KeyboardViewController: UIInputViewController {
guard let self, self.heightPhase == .presented else { return }
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
#if DEBUG
WhatsNewDemoDriver.resetForNewPresentation()
WhatsNewDemoDriver.startIfNeeded(
state: state,
host: WhatsNewDemoDriver.HostHooks(
insertText: { [weak self] text in
self?.textDocumentProxy.insertText(text)
},
deleteBackward: { [weak self] in
self?.textDocumentProxy.deleteBackward()
},
contextBeforeInput: { [weak self] in
self?.textDocumentProxy.documentContextBeforeInput
},
performReturn: { [weak self] in
self?.textDocumentProxy.insertText("\n")
}
)
)
#endif
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
@@ -275,12 +301,14 @@ public final class KeyboardViewController: UIInputViewController {
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
typingSession.synchronizeEnglishDocumentContext()
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
public override func selectionDidChange(_ textInput: UITextInput?) {
super.selectionDidChange(textInput)
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext()
}
@@ -325,11 +353,13 @@ public final class KeyboardViewController: UIInputViewController {
refreshLayoutMode()
cursorDrag?.layoutChrome()
enforcePresentedKeyboardHeightIfNeeded()
logLayoutSnapshotIfChanged()
}
// MARK: - Services
private func installServices() {
editHintScheduler = EditHintScheduler(state: state)
textInserter = KeyboardTextInserter(
state: state,
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
@@ -337,7 +367,8 @@ public final class KeyboardViewController: UIInputViewController {
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
fieldContextProvider: { [weak self] in self?.captureFieldContext() },
selectedText: { [weak self] in self?.textDocumentProxy.selectedText },
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() }
scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
editHintScheduler: editHintScheduler
)
configSync = KeyboardConfigSync(
@@ -375,7 +406,8 @@ public final class KeyboardViewController: UIInputViewController {
abortFlow: { [weak self] in self?.flowCoordinator.abortEditRecording() },
acknowledge: { [weak self] outcome in
self?.flowCoordinator.acknowledgeEditResult(outcome)
}
},
editHintScheduler: editHintScheduler
)
flowCoordinator.onEditHostRecordingConfirmed = { [weak self] in
self?.lastInputEditCoordinator.hostRecordingConfirmed()
@@ -476,6 +508,9 @@ public final class KeyboardViewController: UIInputViewController {
state.sendAIAnswer = { [weak self] in
self?.aiKeyboardCoordinator.sendLatestAnswer()
}
state.submitAIHint = { [weak self] card in
self?.aiKeyboardCoordinator.submitHintCard(card)
}
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -594,18 +629,22 @@ public final class KeyboardViewController: UIInputViewController {
}
private func applyPreferredSurfaceOnOpen() {
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
let preference = TypingInputConfiguration.preferredOpenPreference()
let resolved = KeyboardOpenSurfacePolicy.resolve(
locksTypingSurface: state.locksTypingSurface,
preferred: preferred
preferred: preference.surface
)
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
"applyPreferredSurfaceOnOpen preferred=\(preference.surface.rawValue) "
+ "resolved=\(resolved.rawValue) "
+ "lang=\(preference.typingLanguage?.rawValue ?? "-") "
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
category: "boot"
)
applySurface(resolved)
if resolved == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language)
}
if resolved == .ai {
aiKeyboardCoordinator.beginNewPresentation()
}
@@ -615,26 +654,20 @@ public final class KeyboardViewController: UIInputViewController {
/// so a reused keyboard instance does not animate voice typing on show.
private func prepareSurfaceForNextPresentation() {
guard !TypingInputConfiguration.remembersLastSurface() else { return }
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
guard state.surface != preferred else { return }
state.surface = preferred
let preference = TypingInputConfiguration.preferredOpenPreference()
if state.surface != preference.surface {
state.surface = preference.surface
}
if preference.surface == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language)
}
}
private func refreshKeyboardHeight() {
// `applyPresentationHeightOffset()` is only a one-time presentation
// primer used before `viewDidAppear`. Reusing it after a surface
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
if heightPhase == .presented {
// Avoid synchronous layout here: this is also called during
// `viewDidLoad`, where re-entrant layout can observe partially
// initialized controller dependencies.
lockPresentedKeyboardHeight()
} else {
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
}
}
private func refreshLayoutMode() {
@@ -659,7 +692,6 @@ public final class KeyboardViewController: UIInputViewController {
private var heightPhaseLog: String {
switch heightPhase {
case .idle: return "idle"
case .priming: return "priming"
case .presented: return "presented"
}
}
@@ -669,8 +701,8 @@ public final class KeyboardViewController: UIInputViewController {
view.setNeedsLayout()
}
/// After presentation, the constraint must stay at `target`. Spurious
/// lifecycle noise must not leave us primed at `target encapsulated`.
/// The constraint must stay at `target` once presented, whatever the system
/// did to the input view's height during the transition.
private func enforcePresentedKeyboardHeightIfNeeded() {
guard heightPhase == .presented else { return }
let target = targetKeyboardHeight
@@ -687,7 +719,8 @@ public final class KeyboardViewController: UIInputViewController {
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
state.isSecureTextEntry = isSecure
state.setSecureTextEntry(isSecure)
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
// Secure fields must not run English autocomplete / autocorrect / learning.
typingSession.suggestionsEnabled = !isSecure
typingSession.syncAutocapitalization()
@@ -697,6 +730,9 @@ public final class KeyboardViewController: UIInputViewController {
typingSession.precedingTextProvider = { [weak self] in
self?.textDocumentProxy.documentContextBeforeInput
}
typingSession.followingTextProvider = { [weak self] in
self?.textDocumentProxy.documentContextAfterInput
}
typingSession.autocapitalizationModeProvider = { [weak self] in
Self.typingAutocapitalizationMode(
for: self?.textDocumentProxy.autocapitalizationType ?? .sentences
@@ -766,18 +802,36 @@ public final class KeyboardViewController: UIInputViewController {
keyboardHeightConstraint = constraint
}
private func applyPresentationHeightOffset() {
// Only valid while priming the slide-in. Callers must not invoke this
// after `heightPhase == .presented`.
if let encapsulated = view.constraints.first(where: { constraint in
constraint.firstItem as? UIView === view
&& constraint.firstAttribute == .height
&& constraint !== keyboardHeightConstraint
}) {
systemEncapsulatedHeight = encapsulated.constant
/// Every height constraint the system and we put on the input view. The
/// system's own constant walks from the full screen height down to the
/// keyboard slot during the slide-in, so this is what to check whenever the
/// surface appears mis-sized or off-slot.
private func logHeightConstraints(tag: String) {
let heights = view.constraints.filter { constraint in
constraint.firstItem as? UIView === view && constraint.firstAttribute == .height
}
let primed = targetKeyboardHeight - systemEncapsulatedHeight
keyboardHeightConstraint?.constant = max(0, primed)
let described = heights.map { constraint in
let name = constraint === keyboardHeightConstraint
? "ours"
: (constraint.identifier ?? "system")
return "\(name)=\(Int(constraint.constant))@\(Int(constraint.priority.rawValue))"
+ (constraint.isActive ? "" : "(inactive)")
}
OSGDiag.log(
"KVC.heightConstraints[\(tag)] \(described.joined(separator: " "))",
category: "boot"
)
}
private func logLayoutSnapshotIfChanged() {
let snapshot = "phase=\(heightPhaseLog) "
+ "view=\(Int(view.bounds.height)) "
+ "host=\(Int(hosting?.view.bounds.height ?? -1)) "
+ "constraint=\(Int(keyboardHeightConstraint?.constant ?? -1)) "
+ "target=\(Int(targetKeyboardHeight))"
guard snapshot != lastLoggedLayoutSnapshot else { return }
lastLoggedLayoutSnapshot = snapshot
OSGDiag.log("KVC.layout \(snapshot)", category: "boot")
}
private func installSwiftUI() {
@@ -66,6 +66,38 @@ final class AIKeyboardCoordinator {
}
}
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
func submitHintCard(_ card: AIHintCard) {
switch state.aiSession.phase {
case .inactive, .idle, .failed:
break
case .preparing, .listening, .recognizing, .generating,
.ready, .awaitingSend, .inserted, .sent:
return
}
enterIfNeeded()
let resolution = AIHintPool.resolvePrompt(
for: card,
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
)
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
guard let conversationID = state.aiSession.conversationID else { return }
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
)
if case .rejected(let rejection) = disposition {
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
flow.cancelAIRecording()
+19 -16
View File
@@ -34,7 +34,7 @@ public struct AppGroupPersistor {
// Both engines always polish; ignore legacy off/transcribe modeId.
state.mode = .polish
state.engineMode = store.engineMode
// v0.2.1 follow-up: only the target locale is persisted
// Only the target locale is persisted;
// `translationEnabled` is derived from it. Hydrate once at
// startup; `refreshRuntimeFlags` keeps the chip in sync while
// the keyboard stays open.
@@ -47,23 +47,26 @@ public struct AppGroupPersistor {
applyAPIKeyAvailability(store: store, into: state)
#if DEBUG
// Print a masked view of the live App Group config so we can see
// from the device console exactly what the keyboard extension
// actually sees (and whether it agrees with the main App).
let key = store.apiKey
let masked: String
if key.count > 8 {
masked = "\(key.prefix(4))\(key.suffix(4)) (\(key.count) chars)"
} else if key.isEmpty {
masked = "<empty>"
} else {
masked = "<\(key.count) chars>"
// Log only credential availability and the base URL origin. Never put
// credential fragments or URL path/query/userinfo into device logs.
let credentialStatus: String
switch Keychain.apiKeyOutcome(for: store.providerId, preferICloudSync: true) {
case .found(let value):
credentialStatus = value.isEmpty ? "empty" : "configured"
case .notFound:
credentialStatus = store.apiKey.isEmpty ? "empty" : "configured"
case .unavailable:
credentialStatus = "keychainUnavailable"
}
let components = URLComponents(string: store.baseURL)
let baseURLOrigin = components?.scheme.flatMap { scheme in
components?.host.map { host in "\(scheme)://\(host)" }
} ?? "<invalid>"
print("""
🔍 [AppGroupPersistor.load]
providerId = \(store.providerId)
baseURL = \(store.baseURL)
apiKey = \(masked)
baseURLOrigin = \(baseURLOrigin)
credential = \(credentialStatus)
model = \(store.model)
modeId = \(store.modeId)
localeId = \(store.localeId)
@@ -136,11 +139,11 @@ public struct AppGroupPersistor {
AppGroupStore().setEngineMode(engineMode)
}
/// v0.2.1: persist translation target locale id (e.g. `"en"`,
/// Persist translation target locale id (e.g. `"en"`,
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
///
/// v0.2.1 follow-up: removed `persist(translationEnabled:)` the
/// There is no `persist(translationEnabled:)` because the
/// enabled state is derived from the locale id, so callers only
/// need to write the locale. Keeping the legacy Bool overload
/// around would have implied that there's a separate on/off
@@ -8,20 +8,51 @@ import Foundation
import UIKit
import OSGKeyboardShared
@MainActor
protocol ClipboardPasteboardProviding: AnyObject {
var changeCount: Int { get }
var hasStrings: Bool { get }
var string: String? { get }
}
@MainActor
final class SystemClipboardPasteboard: ClipboardPasteboardProviding {
var changeCount: Int { UIPasteboard.general.changeCount }
var hasStrings: Bool { UIPasteboard.general.hasStrings }
var string: String? { UIPasteboard.general.string }
}
@MainActor
final class ClipboardCaptureCoordinator {
/// Universal Clipboard may synchronously fetch from another device for
/// seconds. System pasteboard reads must never block keyboard presentation.
private static let readQueue = DispatchQueue(
label: "com.osgkeyboard.clipboard.read",
qos: .utility
)
private static let pollInterval: TimeInterval = 0.8
private let state: KeyboardState
private let history: ClipboardHistoryStore
private let pasteboard: ClipboardPasteboardProviding
private var pollTimer: Timer?
private var isSecureProvider: () -> Bool = { false }
private var hasFullAccessProvider: () -> Bool = { false }
/// Ephemeral only: leaving a secure field must not resurrect old body text.
private var secureFieldSuppressedChangeCount: Int?
private var isSecureEntryActive = false
private var isSampling = false
private var forcesNextSample = true
private var isKeyboardVisible = false
init(
state: KeyboardState,
history: ClipboardHistoryStore = .shared
history: ClipboardHistoryStore = .shared,
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
) {
self.state = state
self.history = history
self.pasteboard = pasteboard
}
func configure(
@@ -33,22 +64,54 @@ final class ClipboardCaptureCoordinator {
}
func keyboardDidAppear() {
isKeyboardVisible = true
history.reload()
refreshSuggestionFromStore()
captureIfNeeded(force: true)
// A suggestion belongs to one keyboard presentation. Clear any
// presentation state left behind by a reused extension controller.
endCurrentSuggestion()
forcesNextSample = true
// Delay the system pasteboard read until the first poll tick. A
// Universal Clipboard fetch or paste alert during the appear sequence
// can otherwise freeze the keyboard before SwiftUI draws.
if !(pasteboard is SystemClipboardPasteboard) {
captureIfNeeded(forceRead: true)
}
startPolling()
}
func keyboardWillDisappear() {
isKeyboardVisible = false
stopPolling()
// A1 policy: closing the keyboard ends this generation's suggestion.
endCurrentSuggestion()
}
func refreshFlagsFromStore() {
// Called from App Group poll suggestion visibility may change.
refreshSuggestionFromStore()
// Settings changes may hide the active suggestion, but enabling the
// strip must wait for a new pasteboard generation.
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
endCurrentSuggestion()
}
}
func secureEntryDidChange(isSecure: Bool) {
if isSecure {
isSecureEntryActive = true
secureFieldSuppressedChangeCount = pasteboard.changeCount
} else if isSecureEntryActive {
// Capture the latest generation once more on exit so a pasteboard
// change near the secure-field transition cannot be persisted.
secureFieldSuppressedChangeCount = pasteboard.changeCount
isSecureEntryActive = false
} else {
return
}
endCurrentSuggestion()
state.clipboardOverlay = .none
}
func openPanelFromTopButton() {
guard state.canShowClipboardEntry else { return }
if state.clipboardHistoryEnabled {
history.reload()
state.clipboardOverlay = .historyPanel
@@ -62,15 +125,15 @@ final class ClipboardCaptureCoordinator {
}
func noteUserDidInputText() {
clearSuggestion(persistDismiss: false)
endCurrentSuggestion()
}
func dismissSuggestion() {
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
clearSuggestion(persistDismiss: true)
endCurrentSuggestion()
}
func insertText(_ text: String, via insert: (String) -> Void) {
guard state.canShowClipboardEntry else { return }
insert(text)
// Tapping a suggestion (or history row that shares this path) must not
// resurface the same clipboard changeCount until the pasteboard changes.
@@ -79,24 +142,28 @@ final class ClipboardCaptureCoordinator {
}
func clearHistory() {
endCurrentSuggestion()
history.clearAll()
clearSuggestion(persistDismiss: false)
}
func deleteEntry(id: UUID) {
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
history.remove(id: id)
refreshSuggestionFromStore()
if deletedChangeCount == state.clipboardSuggestionChangeCount {
endCurrentSuggestion()
}
}
// MARK: - Capture
private func startPolling() {
stopPolling()
let timer = Timer(timeInterval: 0.8, repeats: true) { [weak self] _ in
let timer = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.captureIfNeeded(force: false)
self?.captureIfNeeded()
}
}
timer.tolerance = Self.pollInterval / 4
RunLoop.main.add(timer, forMode: .common)
pollTimer = timer
}
@@ -106,83 +173,170 @@ final class ClipboardCaptureCoordinator {
pollTimer = nil
}
private func captureIfNeeded(force: Bool) {
func captureIfNeeded(forceRead: Bool = false) {
guard state.clipboardHistoryEnabled else {
clearSuggestion(persistDismiss: false)
endCurrentSuggestion()
return
}
#if DEBUG
// What's New demo seeds history itself never touch the pasteboard
// (avoids the simulator alert mid-recording).
if WhatsNewDemoScenario.peek() != nil || WhatsNewDemoScenario.isPlaying() {
return
}
#endif
guard hasFullAccessProvider() else { return }
guard !isSecureProvider() else { return }
let pasteboard = UIPasteboard.general
let changeCount = pasteboard.changeCount
if !force, changeCount == history.lastObservedChangeCount {
refreshSuggestionFromStore()
guard !isSecureProvider() else {
secureEntryDidChange(isSecure: true)
return
}
if pasteboard is SystemClipboardPasteboard {
beginSystemSample(forceRead: forceRead || forcesNextSample)
forcesNextSample = false
return
}
captureInjectedPasteboard(forceRead: forceRead)
}
/// Synchronous path retained for deterministic tests and injected fakes.
/// Production always uses `beginSystemSample` below.
private func captureInjectedPasteboard(forceRead: Bool) {
let changeCount = pasteboard.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
if isCurrentGeneration && !forceRead {
return
}
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
history.lastObservedChangeCount = changeCount
refreshSuggestionFromStore()
return
}
let raw = pasteboard.string
// The forced appearance read exists only to establish/refresh iOS
// paste permission. It must not reinsert or republish old content.
if isCurrentGeneration {
return
}
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
refreshSuggestionFromStore()
}
}
private func refreshSuggestionFromStore() {
guard state.clipboardHistoryEnabled,
state.clipboardCandidateBarEnabled,
!isSecureProvider(),
let newest = history.newestEntry
else {
clearSuggestion(persistDismiss: false)
private struct Sample: Sendable {
let changeCount: Int
let hasStrings: Bool
let text: String?
}
private func beginSystemSample(forceRead: Bool) {
guard !isSampling else { return }
isSampling = true
let lastObserved = history.lastObservedChangeCount
Self.readQueue.async {
let pasteboard = UIPasteboard.general
let changeCount = pasteboard.changeCount
guard forceRead || changeCount != lastObserved else {
Task { @MainActor [weak self] in
self?.isSampling = false
}
return
}
let changeCount = newest.changeCount ?? history.lastObservedChangeCount
guard history.shouldShowSuggestion(
forChangeCount: changeCount,
candidateBarEnabled: state.clipboardCandidateBarEnabled,
historyEnabled: state.clipboardHistoryEnabled
) else {
clearSuggestion(persistDismiss: false)
let hasStrings = pasteboard.hasStrings
let sample = Sample(
changeCount: changeCount,
hasStrings: hasStrings,
text: hasStrings ? pasteboard.string : nil
)
Task { @MainActor [weak self] in
self?.finishSystemSample(sample)
}
}
}
private func finishSystemSample(_ sample: Sample) {
isSampling = false
guard isKeyboardVisible,
state.clipboardHistoryEnabled,
hasFullAccessProvider(),
!isSecureProvider()
else { return }
let changeCount = sample.changeCount
if ClipboardHistoryPolicy.shouldSuppressCapture(
changeCount: changeCount,
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
) {
history.lastObservedChangeCount = changeCount
clearSuggestion()
return
}
// Don't resurrect a strip the user already dismissed this session
// unless changeCount advanced (handled in ingest).
if state.clipboardSuggestionText == nil,
let dismissed = history.suggestionDismissedChangeCount,
dismissed == changeCount {
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
guard sample.hasStrings else {
history.lastObservedChangeCount = changeCount
return
}
state.clipboardSuggestionText = newest.text
state.clipboardSuggestionChangeCount = changeCount
// Forced appearance reads establish iOS paste permission only. Never
// republish content from an already observed generation.
guard !isCurrentGeneration else { return }
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
updateSuggestion(with: entry, changeCount: changeCount)
} else {
history.lastObservedChangeCount = changeCount
}
}
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
guard state.clipboardCandidateBarEnabled else {
clearSuggestion(persistDismiss: false)
guard state.canShowClipboardEntry,
state.clipboardCandidateBarEnabled,
changeCount != secureFieldSuppressedChangeCount
else {
clearSuggestion()
return
}
// Already used/dismissed this pasteboard generation keep it hidden.
if history.suggestionDismissedChangeCount == changeCount {
clearSuggestion(persistDismiss: false)
clearSuggestion()
return
}
state.clipboardSuggestionText = entry.text
state.clipboardSuggestionChangeCount = changeCount
}
private func clearSuggestion(persistDismiss: Bool) {
if persistDismiss {
// already written in dismissSuggestion
private func endCurrentSuggestion() {
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
clearSuggestion()
}
private func clearSuggestion() {
guard state.clipboardSuggestionText != nil
|| state.clipboardSuggestionChangeCount != nil
else {
return
}
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
@@ -0,0 +1,71 @@
// EditHintScheduler.swift
// OSGKeyboard · Keyboard Extension
//
// Owns the edit-hint lifetime so every producer shares one expiration order.
import Foundation
import OSGKeyboardShared
@MainActor
final class EditHintScheduler {
typealias Sleeper = @MainActor (Duration) async -> Void
private let state: KeyboardState
private let sleeper: Sleeper
private var task: Task<Void, Never>?
private var generation: UInt64 = 0
init(
state: KeyboardState,
sleeper: @escaping Sleeper = { duration in
try? await Task.sleep(for: duration)
}
) {
self.state = state
self.sleeper = sleeper
}
func show(message: String, isPositive: Bool, duration: Duration) {
let scheduledGeneration = advanceGeneration()
task?.cancel()
state.editHint = message
state.editHintIsPositive = isPositive
let sleeper = sleeper
task = Task { @MainActor [weak self, sleeper] in
await sleeper(duration)
guard let self, self.generation == scheduledGeneration else {
return
}
self.task = nil
self.clearHint()
}
}
func clearPositive() {
guard state.editHintIsPositive else { return }
advanceGeneration()
task?.cancel()
task = nil
clearHint()
}
func invalidate() {
advanceGeneration()
task?.cancel()
task = nil
clearHint()
}
@discardableResult
private func advanceGeneration() -> UInt64 {
generation &+= 1
return generation
}
private func clearHint() {
state.editHint = nil
state.editHintIsPositive = false
}
}
@@ -316,10 +316,6 @@ final class KeyboardFlowCoordinator {
snapshotReason: readySnapshot?.reason
)
state.flowSessionActive = sessionActive
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
state.debugHasFullAccess = hasFullAccess()
state.micVoiceAvailability = MicVoiceAvailabilityResolver.resolve(
phase: state.phase,
micDisabled: state.micDisabled,
@@ -641,6 +637,17 @@ final class KeyboardFlowCoordinator {
startUtterance(.aiQuestion(conversationID: conversationID))
}
func submitAIQuestion(
text: String,
conversationID: UUID
) -> FlowUtteranceStartDisposition {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .rejected(.pipelineBusy) }
return startUtterance(
.aiQuestion(conversationID: conversationID, prefilledQuestion: trimmed)
)
}
func stopAIRecording() {
guard currentUtteranceRequest?.isAIQuestion == true else { return }
pressEnded()
@@ -1223,7 +1230,7 @@ final class KeyboardFlowCoordinator {
"status=\(result.status.rawValue) "
+ "kind=\(result.errorKind?.rawValue ?? "none") "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(result.text ?? "nil")"
+ "messageLen=\(result.text?.count ?? 0)"
)
isAwaitingFlowResult = false
stopFlowWatchdog()
@@ -1738,6 +1745,26 @@ final class KeyboardFlowCoordinator {
}
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
lastStoppedUtteranceId = nil
// Prefilled AI hint: skip mic / ASR and ask the host to answer text.
if currentUtteranceRequest?.isAIQuestion == true,
let question = currentUtteranceRequest?.aiQuestionText?
.trimmingCharacters(in: .whitespacesAndNewlines),
!question.isEmpty {
writeSubmitAIQuestion(question)
isFlowRecording = false
isAwaitingFlowResult = true
state.lastTranscript = question
state.phase = .processing
if let currentUtteranceId {
onAIRecognitionStarted(currentUtteranceId)
}
startFlowResultWatchdog()
recomputeMicVoiceAvailability()
traceState("startFlowRecording.submitAIQuestion")
return
}
writeCommand(.startRecording)
isFlowRecording = true
state.lastTranscript = ""
@@ -1759,6 +1786,37 @@ final class KeyboardFlowCoordinator {
traceState("startFlowRecording.started")
}
private func writeSubmitAIQuestion(_ text: String) {
guard let activeSessionId, let currentUtteranceId else { return }
let command = FlowCommand(
sessionId: activeSessionId,
utteranceId: currentUtteranceId,
commandSeq: nextCommandSeq(),
action: .submitAIQuestion,
localeId: state.localeId,
utteranceMode: .aiQuestion,
aiConversationID: currentUtteranceRequest?.aiConversationID,
aiQuestionText: text,
startDeadlineAt: currentStartDeadlineAt
)
FlowSessionBridge.writeCommand(command)
if let currentStartDeadlineAt {
FlowSessionBridge.writeStartTransaction(
FlowStartTransaction(
sessionID: activeSessionId,
utteranceID: currentUtteranceId,
deadlineAt: currentStartDeadlineAt,
phase: .issued
)
)
}
FlowTrace.keyboard(
"command.submitAIQuestion",
"seq=\(command.commandSeq) utterance=\(currentUtteranceId.uuidString.prefix(8)) "
+ "chars=\(text.count)"
)
}
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
@@ -2012,7 +2070,7 @@ final class KeyboardFlowCoordinator {
"via=resultWatchdog status=\(result.status.rawValue) "
+ "kind=\(error.kind.rawValue) "
+ "utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "message=\(error.message)"
+ "messageLen=\(error.message.count)"
)
self.state.phase = .error(
.fromFlowTranscription(error),
@@ -21,6 +21,7 @@ final class KeyboardTextInserter {
private let fieldContextProvider: () -> FlowFieldContext?
private let selectedText: () -> String?
private let scheduleAutoClearError: () -> Void
private unowned let editHintScheduler: EditHintScheduler
/// Exact string last inserted through this inserter dictation, AI answer,
/// edit result or clipboard paste (including any word-boundary separator).
@@ -33,7 +34,6 @@ final class KeyboardTextInserter {
private var redoContextBefore: String?
private let extensionInstanceID = UUID()
private var lastEditUndo: PendingTextEditTransaction?
private var editHintTask: Task<Void, Never>?
/// Suppresses availability refresh while we walk `deleteBackward`
/// for undo, so intermediate contexts don't flicker the button.
private var isUndoing = false
@@ -45,7 +45,8 @@ final class KeyboardTextInserter {
contextBeforeInput: @escaping () -> String?,
fieldContextProvider: @escaping () -> FlowFieldContext?,
selectedText: @escaping () -> String?,
scheduleAutoClearError: @escaping () -> Void
scheduleAutoClearError: @escaping () -> Void,
editHintScheduler: EditHintScheduler
) {
self.state = state
self.insertText = insertText
@@ -54,6 +55,7 @@ final class KeyboardTextInserter {
self.fieldContextProvider = fieldContextProvider
self.selectedText = selectedText
self.scheduleAutoClearError = scheduleAutoClearError
self.editHintScheduler = editHintScheduler
}
func handleFlowTranscript(
@@ -510,28 +512,16 @@ final class KeyboardTextInserter {
extensionInstanceID: extensionInstanceID
)
)
editHintTask?.cancel()
let hint = ExtL10n.string("keyboard.edit.hint.available")
state.editHint = hint
state.editHintIsPositive = true
editHintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 10_000_000_000)
guard !Task.isCancelled,
self?.state.editHint == hint,
self?.state.editHintIsPositive == true else {
return
}
self?.state.editHint = nil
self?.state.editHintIsPositive = false
}
editHintScheduler.show(
message: hint,
isPositive: true,
duration: .seconds(10)
)
}
private func clearEditHintIfPositive() {
guard state.editHintIsPositive else { return }
editHintTask?.cancel()
editHintTask = nil
state.editHint = nil
state.editHintIsPositive = false
editHintScheduler.clearPositive()
}
private func clearLastInsertion() {
@@ -15,7 +15,7 @@ final class LastInputEditCoordinator {
private let stopFlow: () -> Void
private let abortFlow: () -> Void
private let acknowledge: (FlowAck.DeliveryOutcome) -> Void
private var hintTask: Task<Void, Never>?
private unowned let editHintScheduler: EditHintScheduler
private var activeUtteranceID: UUID?
private var reviewedUtteranceID: UUID?
private var reviewedRevision: Int64?
@@ -26,7 +26,8 @@ final class LastInputEditCoordinator {
beginFlow: @escaping (EditableInputReference) -> FlowUtteranceStartDisposition,
stopFlow: @escaping () -> Void,
abortFlow: @escaping () -> Void,
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void,
editHintScheduler: EditHintScheduler
) {
self.state = state
self.textInserter = textInserter
@@ -34,6 +35,7 @@ final class LastInputEditCoordinator {
self.stopFlow = stopFlow
self.abortFlow = abortFlow
self.acknowledge = acknowledge
self.editHintScheduler = editHintScheduler
}
func begin() {
@@ -222,35 +224,14 @@ final class LastInputEditCoordinator {
reviewedRevision = nil
}
func showAvailabilityHintAfterDictation() {
guard textInserter.editableReference() != nil else { return }
showHint(
ExtL10n.string("keyboard.edit.hint.available"),
isPositive: true,
durationNanoseconds: 10_000_000_000
private func showHint(_ message: String) {
editHintScheduler.show(
message: message,
isPositive: false,
duration: .milliseconds(2_500)
)
}
private func showHint(
_ message: String,
isPositive: Bool = false,
durationNanoseconds: UInt64 = 2_500_000_000
) {
hintTask?.cancel()
state.editHint = message
state.editHintIsPositive = isPositive
hintTask = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: durationNanoseconds)
guard !Task.isCancelled,
self?.state.editHint == message,
self?.state.editHintIsPositive == isPositive else {
return
}
self?.state.editHint = nil
self?.state.editHintIsPositive = false
}
}
private func startFailureMessage(
for disposition: FlowUtteranceStartDisposition
) -> String {
@@ -0,0 +1,317 @@
// WhatsNewDemoDriver.swift
// OSGKeyboard · Keyboard Extension (DEBUG-only)
//
// Plays a scripted What's New timeline on the **real** keyboard surface while
// a Notes-like host sits underneath. Mutates the host document via the
// textDocumentProxy so the clip shows a closed loop (insert / replace).
#if DEBUG
import Foundation
import OSGKeyboardShared
@MainActor
enum WhatsNewDemoDriver {
private static var running = false
private static var activeTask: Task<Void, Never>?
/// Document mutations + Return for AI .
struct HostHooks {
var insertText: (String) -> Void
var deleteBackward: () -> Void
var contextBeforeInput: () -> String?
var performReturn: () -> Void
}
/// Keyboard extensions outlive host relaunches always allow a fresh arm.
static func resetForNewPresentation() {
activeTask?.cancel()
activeTask = nil
running = false
WhatsNewDemoScenario.finishPlaying()
}
static func startIfNeeded(state: KeyboardState, host: HostHooks) {
// Peek first so a brief appear/disappear does not burn the arm.
guard WhatsNewDemoScenario.peek() != nil else { return }
// A still-running timeline from a previous host launch must not block
// the next What's New recording.
if running {
resetForNewPresentation()
}
running = true
activeTask = Task { @MainActor in
// Wait for first layout / surface mount over the host.
try? await Task.sleep(nanoseconds: 900_000_000)
guard !Task.isCancelled else {
running = false
return
}
guard let armed = WhatsNewDemoScenario.consume() else {
running = false
return
}
OSGDiag.log(
"WhatsNewDemo start scenario=\(armed.scenario.rawValue) lang=\(armed.language.rawValue)",
category: "boot"
)
polishDemoChrome(state)
switch armed.scenario {
case .edit:
await runEdit(
state: state,
host: host,
original: armed.seedText,
language: armed.language
)
case .ai:
await runAI(state: state, host: host, language: armed.language)
case .clipboard:
await runClipboard(state: state, host: host, language: armed.language)
}
if !Task.isCancelled {
WhatsNewDemoScenario.finishPlaying()
}
running = false
activeTask = nil
}
}
// MARK: - Edit last input
private static func runEdit(
state: KeyboardState,
host: HostHooks,
original: String,
language: WhatsNewDemoScenario.Language
) async {
let edited = language == .en
? "Hi everyone — we'll hold a planning discussion in Conference Room A at 3pm tomorrow. Please be on time."
: "各位好,明天下午三点在 A 会议室召开方案讨论会,请准时参加。"
let reference = EditableInputReference(
displayText: original,
insertedText: original,
postInsertionFingerprint: nil,
extensionInstanceID: UUID()
)
let source = EditSessionSource(reference: reference)
let review = EditReview(
source: source,
resultText: edited,
utteranceID: UUID()
)
state.surface = .voice
state.editCanReplaceOriginal = true
state.phase = .idle
ClipboardHistoryStore.shared.clearAll()
clearClipboardChrome(state)
polishDemoChrome(state)
// Brief idle so the host note + voice mic are visible together.
try? await sleep(1.0)
state.editSession = .listening(source)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.listening")
state.level = 0.45
for _ in 0..<4 {
try? await sleep(0.28)
state.level = Double.random(in: 0.25...0.85)
polishDemoChrome(state)
}
state.editSession = .processing(source)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.processing")
try? await sleep(1.0)
state.editSession = .review(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.review")
try? await sleep(2.4)
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
// Replace host document so the clip closes the loop.
replaceHostText(from: original, to: edited, host: host)
try? await sleep(1.2)
state.editSession = .inactive
state.editCanReplaceOriginal = false
state.phase = .idle
state.lastTranscript = ""
clearClipboardChrome(state)
polishDemoChrome(state)
try? await sleep(1.2)
}
// MARK: - AI keyboard
private static func runAI(
state: KeyboardState,
host: HostHooks,
language: WhatsNewDemoScenario.Language
) async {
let question = language == .en
? "Where should I go this weekend?"
: "周末去哪儿玩比较合适?"
let answer = language == .en
? "Try a nearby town day trip: morning walk in a park or old street, afternoon café, then a local dinner."
: "可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。"
state.surface = .ai
state.aiServiceAvailable = true
ClipboardHistoryStore.shared.clearAll()
clearClipboardChrome(state)
polishDemoChrome(state)
// Chat host uses returnKeyType=.send; keep role locked for the clip.
state.returnKeyRole = .send
state.aiSession.enter()
try? await sleep(0.9)
let utteranceID = UUID()
state.aiSession.beginPreparing(utteranceID: utteranceID)
try? await sleep(0.25)
state.aiSession.beginListening(utteranceID: utteranceID)
state.level = 0.4
for _ in 0..<6 {
try? await sleep(0.18)
state.level = Double.random(in: 0.25...0.9)
state.aiSession.updateTranscript(question, utteranceID: utteranceID)
polishDemoChrome(state)
}
state.aiSession.beginRecognizing(utteranceID: utteranceID)
try? await sleep(0.4)
state.aiSession.beginGenerating(question: question, utteranceID: utteranceID)
try? await sleep(0.45)
let chars = Array(answer)
var index = 0
while index < chars.count {
index = min(chars.count, index + 5)
let draft = String(chars.prefix(index))
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
try? await sleep(0.1)
}
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
polishDemoChrome(state)
try? await sleep(1.3)
// Insert on a new line so seed + answer stay readable in the composer.
host.insertText("\n" + answer)
state.aiSession.markAnswerInserted(offersSend: true)
polishDemoChrome(state)
try? await sleep(1.1)
state.aiSession.markAnswerSent()
host.performReturn()
// Stay on AI surface so the beat is not buried by voice chrome.
state.surface = .ai
clearClipboardChrome(state)
polishDemoChrome(state)
try? await sleep(1.4)
}
// MARK: - Clipboard history
private static func runClipboard(
state: KeyboardState,
host: HostHooks,
language: WhatsNewDemoScenario.Language
) async {
let samples = language == .en
? [
"Meeting at 3pm tomorrow",
"Room moved to Building A, 3F",
"Bring the clicker and the deck"
]
: [
"明天下午三点开会",
"会议室改到 A 栋 3 楼",
"请带上投影笔和方案文档"
]
let store = ClipboardHistoryStore.shared
store.clearAll()
for text in samples.reversed() {
_ = store.ingest(rawText: text, changeCount: Int.random(in: 1...9_999))
}
store.reload()
// Persist flags so AppGroupPersistor cannot flip history off mid-demo.
if let defaults = AppGroup.defaultsIfAvailable {
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardCandidateBarEnabled)
defaults.synchronize()
}
state.surface = .voice
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
state.phase = .idle
state.clipboardOverlay = .none
polishDemoChrome(state)
// Suggestion strip first (matches the docs copy).
state.clipboardSuggestionText = samples[0]
state.clipboardSuggestionChangeCount = 1
try? await sleep(1.2)
polishDemoChrome(state)
state.clipboardOverlay = .historyPanel
try? await sleep(2.6)
polishDemoChrome(state)
// Tap-to-insert: close panel + write into the host field.
state.clipboardOverlay = .none
host.insertText(samples[0])
polishDemoChrome(state)
try? await sleep(0.9)
// Leave a fresh suggestion strip visible for the next copy cue.
state.clipboardSuggestionText = samples[1]
state.clipboardSuggestionChangeCount = 2
// Hold suggestion with chrome locked clean (no Flow warning flash).
for _ in 0..<8 {
polishDemoChrome(state)
try? await sleep(0.2)
}
}
// MARK: - Host helpers
private static func polishDemoChrome(_ state: KeyboardState) {
state.micDisabledHint = ""
state.micVoiceAvailability = .ready
}
private static func clearClipboardChrome(_ state: KeyboardState) {
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.clipboardSuggestionChangeCount = nil
}
private static func replaceHostText(
from original: String,
to edited: String,
host: HostHooks
) {
// Prefer deleting only the seed suffix so we don't wipe unrelated text.
let before = host.contextBeforeInput() ?? ""
let deleteCount: Int
if before.hasSuffix(original) {
deleteCount = original.count
} else if !before.isEmpty {
deleteCount = before.count
} else {
deleteCount = original.count
}
for _ in 0..<deleteCount {
host.deleteBackward()
}
host.insertText(edited)
}
/// ~1.6× slow-mo for screen recording readability (30 fps source).
private static func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 1.6 * 1_000_000_000))
}
}
#endif
@@ -8,6 +8,7 @@ import OSGKeyboardShared
struct KeyboardSurfaceRoot: View {
@Environment(\.colorScheme) private var colorScheme
@Namespace private var keyboardTabSelectionNamespace
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@@ -68,6 +69,16 @@ struct KeyboardSurfaceRoot: View {
// Overlays are siblings of the surfaces, so the palette has to be
// injected here or they fall back to the environment's dark default.
.environment(\.themePalette, palette)
// The input surfaces are replaced when switching tabs. Keep one
// namespace above that switch so the selected glass pill can morph
// between the outgoing and incoming top-control instances.
.environment(\.keyboardTabSelectionNamespace, keyboardTabSelectionNamespace)
// Bottom-anchored on purpose: UIKit hands the input view a container up
// to the full screen height while the keyboard slides in, and centering
// a fixed-height surface in it parks the whole keyboard above the
// visible slot which reads as a blank keyboard whenever that frame
// lingers (a slow Universal Clipboard read, a system alert).
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface != .typing {
@@ -90,6 +101,9 @@ struct KeyboardSurfaceRoot: View {
@ViewBuilder
private var clipboardOverlayLayer: some View {
if !state.canShowClipboardEntry {
EmptyView()
} else {
switch state.clipboardOverlay {
case .none:
EmptyView()
@@ -113,3 +127,4 @@ struct KeyboardSurfaceRoot: View {
}
}
}
}
+4 -6
View File
@@ -122,7 +122,9 @@ struct TypingRootView: View {
if hasCandidateContent {
// Composing Chinese/English candidates hide the clipboard strip.
candidateBar
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Same slot as logo + capsule tabs hide chrome until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -548,6 +550,7 @@ struct TypingRootView: View {
)
.fill(visualKeyFill(for: key, pressed: showPressed))
)
// NativeKeyboardKeySurface +
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
@@ -555,11 +558,6 @@ struct TypingRootView: View {
)
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(showPressed ? 0.04 : 0.13),
radius: showPressed ? 0.5 : 1,
y: showPressed ? 0 : 1
)
.scaleEffect(pressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: pressed)
.accessibilityElement()
+4 -1
View File
@@ -11,7 +11,10 @@ import OSGKeyboardShared
enum ExtL10n {
private static let table = "Keyboard"
private static let container = Bundle(for: KeyboardViewController.self)
/// Anchor in whichever target compiles this file (extension or main-app
/// DEBUG what's-new host). Avoids coupling to `KeyboardViewController`.
private final class BundleAnchor {}
private static let container = Bundle(for: BundleAnchor.self)
private static var bundle: Bundle {
AppUILanguage.localizedBundle(
+111 -25
View File
@@ -1,8 +1,8 @@
// AIKeyboardView.swift
// OSGKeyboard · Keyboard Extension
//
// Temporary voice-to-AI surface. The latest answer remains visible while a
// follow-up is running and is inserted only through the explicit Send action.
// Product voice-to-AI conversation surface. The latest answer remains visible
// while a follow-up runs and is inserted only through the explicit Send action.
import SwiftUI
import OSGKeyboardShared
@@ -14,13 +14,23 @@ struct AIKeyboardView: View {
static let actionButtonHeight: CGFloat = 50
static let actionButtonMaxWidth: CGFloat = 150
static let statusHeight: CGFloat = 20
static let carouselInterval: TimeInterval = 4
}
@Environment(\.colorScheme) private var colorScheme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
/// A copy made while the keyboard is visible must reach the carousel
/// immediately, not on the next rotation tick.
@ObservedObject private var clipboardHistory = ClipboardHistoryStore.shared
let onInsert: (String) -> Void
@State private var currentHint: AIHintCard?
@State private var hintOpacity: Double = 1
@State private var carouselBag = AIHintCarouselBag()
@State private var poolCards: [AIHintCard] = []
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -37,6 +47,26 @@ struct AIKeyboardView: View {
.frame(maxWidth: .infinity)
.frame(height: resolvedHeight)
.environment(\.themePalette, palette)
.onAppear { resetCarousel() }
.onChange(of: state.aiSession.phase) { _, phase in
guard phase == .idle || phase == .failed else { return }
resetCarousel()
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
guard showsPlaceholder else { return }
// Reduce Motion stops the rotation, not the data: a card whose
// clipboard window has closed must still leave the carousel.
reloadHintPool(resetBag: false)
if reduceMotion, let hint = currentHint,
poolCards.contains(where: { $0.id == hint.id }) {
return
}
showNextHint(animated: !reduceMotion)
}
}
private var resolvedHeight: CGFloat {
@@ -69,7 +99,9 @@ struct AIKeyboardView: View {
)
}
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
} else if let suggestion = state.clipboardSuggestionText, !suggestion.isEmpty {
} else if state.canShowClipboardEntry,
let suggestion = state.clipboardSuggestionText,
!suggestion.isEmpty {
// Replaces logo + capsule tabs until dismissed.
ClipboardSuggestionBar(
text: suggestion,
@@ -95,12 +127,7 @@ struct AIKeyboardView: View {
private var answerArea: some View {
ZStack(alignment: .bottom) {
if showsPlaceholder {
// Empty-state tip: geometric center of the answer plane.
Text(ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.md)
hintCarousel
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
@@ -141,7 +168,36 @@ struct AIKeyboardView: View {
}
}
/// No draft/answer yet show the centered mic guidance instead of a scroll body.
private var hintCarousel: some View {
Button {
guard let hint = currentHint else { return }
state.submitAIHint(hint)
} label: {
Text(currentHint?.displayText ?? ExtL10n.string("keyboard.ai.placeholder"))
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.lineLimit(1)
.truncationMode(.tail)
.padding(.horizontal, Spacing.md)
.opacity(hintOpacity)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// A busy session already owns the surface; the status line explains a
// missing LLM. Both keep the hint from being a tap with no outcome.
.disabled(currentHint == nil || !state.aiServiceAvailable || state.aiSession.isBusy)
.accessibilityLabel(
Text(
currentHint.map {
"\(ExtL10n.string("keyboard.ai.hintA11yPrefix"))\($0.displayText)"
} ?? ExtL10n.string("keyboard.ai.placeholder")
)
)
}
/// No draft/answer yet show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
return !hasDraft && state.aiSession.answer == nil
@@ -173,7 +229,7 @@ struct AIKeyboardView: View {
private var aiMicrophoneButton: some View {
Button(action: state.tapAIMic) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if state.aiSession.phase == .listening {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -187,6 +243,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -228,11 +286,8 @@ struct AIKeyboardView: View {
minHeight: Layout.actionButtonHeight,
maxHeight: Layout.actionButtonHeight
)
.background(
answerActionFill,
in: Capsule()
)
.overlay(Capsule().stroke(answerActionBorder, lineWidth: 0.5))
//
.background(answerActionFill, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -269,11 +324,11 @@ struct AIKeyboardView: View {
private var answerActionFill: Color {
guard state.aiSession.canPerformAnswerAction else {
return palette.surfaceElevated
return palette.surfaceElevated.opacity(0.55)
}
return state.aiSession.canSend
? palette.accent
: NativeKeyboardKeyColors.fill(for: colorScheme)
: palette.surfaceElevated
}
private var answerActionForeground: Color {
@@ -285,13 +340,6 @@ struct AIKeyboardView: View {
: NativeKeyboardKeyColors.text(for: colorScheme)
}
private var answerActionBorder: Color {
guard state.aiSession.canSend else {
return palette.divider
}
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
private var microphoneDisabled: Bool {
switch state.aiSession.phase {
case .preparing, .recognizing, .generating:
@@ -339,4 +387,42 @@ struct AIKeyboardView: View {
? "keyboard.ai.stopA11y"
: "keyboard.ai.startA11y"
}
// MARK: - Carousel
/// Rebuild the pool and show a card right away, without a fade.
private func resetCarousel() {
reloadHintPool(resetBag: true)
showNextHint(animated: false)
}
private func reloadHintPool(resetBag: Bool) {
let locale = AIHintLocaleResolver.packLocale()
let pack = AIHintStore.resolvedPack(locale: locale)
poolCards = AIHintPool.activeCards(
pack: pack,
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: clipboardHistory.newestEntry
)
if resetBag {
carouselBag.reset()
}
}
private func showNextHint(animated: Bool) {
guard let next = carouselBag.next(from: poolCards) else {
currentHint = nil
return
}
if animated, !reduceMotion {
withAnimation(Motion.soft) { hintOpacity = 0 }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
currentHint = next
withAnimation(Motion.soft) { hintOpacity = 1 }
}
} else {
currentHint = next
hintOpacity = 1
}
}
}
@@ -124,6 +124,7 @@ struct ClipboardEnableGuideView: View {
struct ClipboardHistoryPanelView: View {
@Environment(\.themePalette) private var palette
@ObservedObject var history: ClipboardHistoryStore
@State private var showClearConfirmation = false
let onClose: () -> Void
let onClear: () -> Void
@@ -132,16 +133,20 @@ struct ClipboardHistoryPanelView: View {
let pastePermissionHint: String?
var body: some View {
ZStack {
VStack(spacing: 0) {
ClipboardPanelHeader(onClose: onClose) {
Button(action: onClear) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.font(.system(
size: KeyboardTopBarMetrics.trailingChipIconSize,
weight: .medium
))
.foregroundStyle(palette.textSecondary)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
// HIG minimum hit target; icon stays visually small and centered.
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -180,9 +185,68 @@ struct ClipboardHistoryPanelView: View {
}
}
}
.allowsHitTesting(!showClearConfirmation)
if showClearConfirmation {
clearConfirmationOverlay
.transition(.scale(scale: 0.96).combined(with: .opacity))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Transparent let the system keyboard chrome show through.
.background(Color.clear)
.animation(.easeOut(duration: 0.16), value: showClearConfirmation)
}
private var clearConfirmationOverlay: some View {
VStack(spacing: 12) {
Image(systemName: "trash")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38)
.background(palette.surface.opacity(0.35), in: Circle())
ExtL10n.text("keyboard.clipboard.clear.title")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
HStack(spacing: 10) {
Button {
showClearConfirmation = false
} label: {
ExtL10n.text("common.cancel")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
Button {
// Dismiss the popup before publishing an empty history
// so the keyboard never retains stale row content.
showClearConfirmation = false
onClear()
} label: {
ExtL10n.text("keyboard.clipboard.clear.confirm")
.font(.system(size: 13, weight: .semibold))
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.capsule)
.tint(palette.accent)
}
}
.padding(16)
.frame(maxWidth: 300)
.glassEffect(
.regular,
in: RoundedRectangle(cornerRadius: 18, style: .continuous)
)
.padding(.horizontal, 24)
.accessibilityElement(children: .contain)
}
}
@@ -277,23 +341,25 @@ struct KeyboardClipboardMenuButton: View, Equatable {
var body: some View {
Button(action: action) {
// Neutral chip mirrors the translation button's off state.
// SF Symbol "clipboard" sits optically low; nudge up so it centres
// in the 34pt chip the same way "xmark" does.
Image(systemName: "clipboard")
.font(.system(size: KeyboardTopBarMetrics.trailingChipIconSize, weight: .medium))
.foregroundStyle(palette.textSecondary)
.foregroundStyle(palette.textPrimary.opacity(0.72))
.offset(y: -0.5)
.frame(
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
// Match KeyboardCancelButton: opaque key fill + hairline, no glass.
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(ExtL10n.text("keyboard.clipboard.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.clipboard.a11yHint"))
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
+5 -96
View File
@@ -4,8 +4,8 @@
// Typeless-inspired keyboard surface. The keyboard is laid out in three
// vertical bands, but the entire height is reserved for us we set
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI see presentation offset
// in `applyPresentationHeightOffset()`.
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI the input view is bottom-
// anchored so a transient over-tall system container cannot float the chrome.
//
//
// [OSG] EN header band (top)
@@ -296,6 +296,7 @@ public struct KeyboardRootView: View {
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: micButtonEnabled,
usesLiquidGlass: true,
onToggle: state.tapMic,
onPressingChanged: micButtonEnabled
? state.setMicTouchActive
@@ -425,6 +426,7 @@ public struct KeyboardRootView: View {
systemName: "arrow.uturn.backward",
label: ExtL10n.string("keyboard.undoA11y"),
disabled: disabled,
usesLiquidGlass: true,
hapticIntensity: state.keyboardHapticIntensity
) {
state.undoLastInsertion()
@@ -458,6 +460,7 @@ public struct KeyboardRootView: View {
}
private var shouldShowClipboardSuggestion: Bool {
guard state.canShowClipboardEntry else { return false }
guard let text = state.clipboardSuggestionText, !text.isEmpty else { return false }
return true
}
@@ -701,97 +704,3 @@ private struct TranscriptLine: View {
}
}
}
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
private struct CloudEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
ExtL10n.text("keyboard.placeholder.cloudBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Local engine chip (shown instead of ModeChip when engineMode == "local")
private struct LocalEngineChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
var body: some View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
ExtL10n.text("keyboard.placeholder.localBadge")
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
}
}
// MARK: - Locale chip
private struct LocaleChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
let localeId: String
let onChange: (String) -> Void
private let options: [(id: String, labelKey: String)] = [
("auto", "locale.chip.auto"),
("zh-Hans", "locale.chip.zh-Hans"),
("zh-Hant", "locale.chip.zh-Hant"),
("en-US", "locale.chip.en-US"),
("ja-JP", "locale.chip.ja-JP"),
("ko-KR", "locale.chip.ko-KR")
]
var body: some View {
Menu {
ForEach(options, id: \.id) { o in
Button {
onChange(o.id)
} label: {
if o.id == localeId {
Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
} else {
Text(ExtL10n.string(o.labelKey))
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "globe")
Text(currentLabel)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
.menuStyle(.button)
}
private var currentLabel: String {
options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
?? ExtL10n.string("locale.chip.auto")
}
}
+94 -62
View File
@@ -7,6 +7,17 @@
import SwiftUI
import OSGKeyboardShared
private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil
}
extension EnvironmentValues {
var keyboardTabSelectionNamespace: Namespace.ID? {
get { self[KeyboardTabSelectionNamespaceKey.self] }
set { self[KeyboardTabSelectionNamespaceKey.self] = newValue }
}
}
enum KeyboardTopBarMetrics {
static let height: CGFloat = 44
static let horizontalInset: CGFloat = 12
@@ -44,8 +55,8 @@ struct KeyboardBrandLogo: View {
}
struct KeyboardCancelButton: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let action: () -> Void
let accessibilityLabel: Text
@@ -61,18 +72,17 @@ struct KeyboardCancelButton: View {
width: KeyboardTopBarMetrics.trailingChipSize,
height: KeyboardTopBarMetrics.trailingChipSize
)
.background(buttonFill, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
//
.background(NativeKeyboardKeyColors.fill(for: colorScheme), in: Circle())
.overlay(
Circle().stroke(palette.divider, lineWidth: 0.5)
)
.contentShape(Circle())
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.accessibilityHint(accessibilityHint)
}
private var buttonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
}
}
private enum KeyboardInputTab: CaseIterable {
@@ -83,16 +93,18 @@ private enum KeyboardInputTab: CaseIterable {
var title: String {
switch self {
case .ai: return "AI"
case .voice: return "语音"
case .chinese: return "中文"
case .english: return "EN"
case .ai: return ExtL10n.string("keyboard.tab.ai")
case .voice: return ExtL10n.string("keyboard.tab.voice")
case .chinese: return ExtL10n.string("keyboard.tab.chinese")
case .english: return ExtL10n.string("keyboard.tab.english")
}
}
}
struct KeyboardTopControls: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.keyboardTabSelectionNamespace) private var sharedSelectionNamespace
@Namespace private var fallbackSelectionNamespace
@ObservedObject var state: KeyboardState
@ObservedObject var typing: TypingSessionController
@@ -102,42 +114,19 @@ struct KeyboardTopControls: View {
var body: some View {
HStack(spacing: 6) {
// /
HStack(spacing: 2) {
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
Button {
select(tab)
} label: {
Text(tab.title)
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
.foregroundStyle(
isSelected(tab) ? palette.textPrimary : palette.textSecondary
)
.frame(
width: tab == .english || tab == .ai ? 34 : 42,
height: 30
)
.background {
if isSelected(tab) {
Capsule()
.fill(selectedFill)
.shadow(
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
radius: 1.5,
y: 1
)
}
}
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
tabButton(tab)
}
}
.padding(2)
.background(trackFill, in: Capsule())
.background(tabTrackFill, in: Capsule())
.overlay(
Capsule().stroke(palette.divider, lineWidth: 0.5)
)
if state.canShowClipboardEntry {
KeyboardClipboardMenuButton(
palette: palette,
action: state.openClipboardPanel
@@ -145,9 +134,49 @@ struct KeyboardTopControls: View {
.equatable()
}
}
}
private var selectedFill: Color {
colorScheme == .dark ? Color(white: 0.38) : .white
private func tabButton(_ tab: KeyboardInputTab) -> some View {
let selected = isSelected(tab)
let width: CGFloat = tab == .english || tab == .ai ? 34 : 42
return Button {
withAnimation(Motion.soft) {
select(tab)
}
} label: {
tabLabel(tab, selected: selected, width: width)
}
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
.disabled(tab != .voice && !state.canEnterTypingSurface)
.opacity(tabOpacity(tab))
.accessibilityLabel(accessibilityLabel(for: tab))
.accessibilityAddTraits(selected ? .isSelected : [])
}
@ViewBuilder
private func tabLabel(
_ tab: KeyboardInputTab,
selected: Bool,
width: CGFloat
) -> some View {
let label = Text(tab.title)
.font(.system(size: 12, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? palette.textPrimary : palette.textSecondary)
.frame(width: width, height: 30)
if selected {
let namespace = sharedSelectionNamespace ?? fallbackSelectionNamespace
//
label.background(
Capsule()
.fill(NativeKeyboardKeyColors.fill(for: colorScheme))
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
.matchedGeometryEffect(id: "keyboard-tab-selection", in: namespace)
)
} else {
label
}
}
private func tabOpacity(_ tab: KeyboardInputTab) -> Double {
@@ -158,14 +187,16 @@ struct KeyboardTopControls: View {
return 0.42
}
private var trackFill: Color {
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
}
/// NativeKeyboardKeyColors.fill
///
private var tabTrackFill: Color {
colorScheme == .dark ? Color(white: 0.12) : Color(white: 0.87)
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
switch tab {
case .ai:
@@ -211,17 +242,15 @@ struct KeyboardTopControls: View {
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
switch tab {
case .ai: return "切换到 AI 问答"
case .voice: return "切换到语音输入"
case .chinese: return "切换到中文输入"
case .english: return "切换到英文输入"
case .ai: return ExtL10n.string("keyboard.tab.ai.a11y")
case .voice: return ExtL10n.string("keyboard.tab.voice.a11y")
case .chinese: return ExtL10n.string("keyboard.tab.chinese.a11y")
case .english: return ExtL10n.string("keyboard.tab.english.a11y")
}
}
}
struct KeyboardTranslationMenuButton: View, Equatable {
@Environment(\.colorScheme) private var colorScheme
let palette: ThemePalette
let targetLocaleId: String
let onSelect: (String) -> Void
@@ -251,22 +280,25 @@ struct KeyboardTranslationMenuButton: View, Equatable {
}
}
} label: {
// Match the adjacent undo key: 44×44 rounded-rect chrome, not a circle chip.
NativeKeyboardKeySurface(
isPressed: false,
fill: NativeKeyboardKeyColors.fill(for: colorScheme),
pressedFill: NativeKeyboardKeyColors.pressedFill(for: colorScheme),
border: palette.divider,
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius
) {
// Match the adjacent undo key: 44×44 rounded Liquid Glass control.
ZStack {
Color.clear
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(
isEnabled
? palette.accent
: NativeKeyboardKeyColors.text(for: colorScheme)
: palette.textSecondary
)
}
.contentShape(Rectangle())
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: KeyboardChromeLayout.actionKeyCornerRadius,
style: .continuous
)
)
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
+14 -5
View File
@@ -45,14 +45,19 @@ struct LastInputEditView: View {
.frame(height: KeyboardChromeLayout.totalHeight)
.environment(\.themePalette, palette)
.onChange(of: state.editSession) { _, newValue in
guard newValue.review != nil else {
guard let review = newValue.review else {
selectedPage = 0
return
}
if reduceMotion {
// Set page before the pager remounts (see `.id` on `pages`) so the
// fresh ScrollView opens oninstead of flipping the dots
// while still showing.
selectedPage = 1
} else {
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.86)) {
if !reduceMotion {
// Re-assert after layout; spring is only for subsequent swipes.
Task { @MainActor in
await Task.yield()
guard state.editSession.review?.utteranceID == review.utteranceID else { return }
selectedPage = 1
}
}
@@ -83,6 +88,8 @@ struct LastInputEditView: View {
contentBottomInset: 30,
selectedPage: $selectedPage
)
// Remount when review text arrives so scrollPosition can open on page 1.
.id(state.editSession.review?.utteranceID.uuidString ?? "edit-source")
}
}
@@ -113,7 +120,7 @@ struct LastInputEditView: View {
helperText(leftHelper)
Button(action: primaryAction) {
ZStack {
Capsule().fill(palette.accent)
Color.clear
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
@@ -126,6 +133,8 @@ struct LastInputEditView: View {
width: Layout.primaryButtonWidth,
height: Layout.primaryButtonHeight
)
//
.background(palette.accent, in: Capsule())
.contentShape(Capsule())
}
.buttonStyle(.plain)
@@ -47,15 +47,11 @@ struct NativeKeyboardKeySurface<Content: View>: View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(isPressed ? pressedFill : fill)
)
// + 0.5pt
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(border, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
radius: isPressed ? 0.5 : 1,
y: isPressed ? 0 : 1
)
.scaleEffect(isPressed ? 0.98 : 1)
.animation(.easeOut(duration: 0.08), value: isPressed)
}
+42 -40
View File
@@ -189,39 +189,6 @@ struct RepeatingDeleteButton: View {
}
}
// MARK: - Press-down typing key
/// Fires on touch-down (not release) so click sound / haptic match the stock
/// keyboard and the voice toolbars RectangularToolbarButton.
struct PressDownKeyButton<Label: View>: View {
var disabled: Bool = false
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
action()
}
.onEnded { _ in
isPressing = false
}
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
@@ -233,6 +200,7 @@ struct RectangularToolbarButton: View {
let label: String
let disabled: Bool
let isSend: Bool
let usesLiquidGlass: Bool
/// Settings General Haptics; space / return use `.action` role.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@@ -241,6 +209,7 @@ struct RectangularToolbarButton: View {
systemName: String,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -250,6 +219,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -259,6 +229,7 @@ struct RectangularToolbarButton: View {
label: String,
disabled: Bool = false,
isSend: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -267,6 +238,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = isSend
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
self.title = title
@@ -276,6 +248,7 @@ struct RectangularToolbarButton: View {
spaceStyle: Bool,
label: String,
disabled: Bool = false,
usesLiquidGlass: Bool = false,
hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
@@ -285,6 +258,7 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = false
self.usesLiquidGlass = usesLiquidGlass
self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -292,11 +266,46 @@ struct RectangularToolbarButton: View {
@State private var isPressing = false
var body: some View {
buttonSurface
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@ViewBuilder
private var buttonSurface: some View {
if usesLiquidGlass {
ZStack {
Color.clear
buttonContent
}
.glassEffect(
.regular.interactive(),
in: RoundedRectangle(
cornerRadius: ToolbarButtonMetrics.cornerRadius,
style: .continuous
)
)
// The custom press gesture fires on touch-down; mirror that state
// visually while Liquid Glass supplies its native light response.
.scaleEffect(isPressing ? 0.97 : 1)
.animation(.easeOut(duration: 0.08), value: isPressing)
} else {
ToolbarKeySurface(
isPressed: isPressing,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
emphasis: isSend ? .send : .standard
) {
buttonContent
}
}
}
@ViewBuilder
private var buttonContent: some View {
if spaceStyle {
Capsule()
.fill(buttonForeground)
@@ -311,13 +320,6 @@ struct RectangularToolbarButton: View {
.foregroundStyle(buttonForeground)
}
}
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
private var buttonForeground: Color {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
+14 -15
View File
@@ -114,13 +114,19 @@
"preview.localeChip.cycle" = "Cycle recognition language";
/* Keyboard (ext) */
"keyboard.tab.ai" = "AI";
"keyboard.tab.voice" = "Voice";
"keyboard.tab.chinese" = "中文";
"keyboard.tab.english" = "EN";
"keyboard.tab.ai.a11y" = "Switch to AI Q&A";
"keyboard.tab.voice.a11y" = "Switch to voice input";
"keyboard.tab.chinese.a11y" = "Switch to Chinese typing";
"keyboard.tab.english.a11y" = "Switch to English typing";
"keyboard.placeholder.idle" = "Tap to talk";
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.preparingRecording" = "Preparing mic…";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
"keyboard.placeholder.cloudBadge" = "Cloud";
"keyboard.models.notDownloaded" = "On-device models not downloaded";
"keyboard.models.downloadHint" = "Open OSGKeyboard to download models";
"keyboard.rec" = "REC";
@@ -183,20 +189,8 @@
"keyboard.dictation.failed" = "Recording failed. Try again.";
"keyboard.dictation.resultTimeout" = "Timed out waiting for dictation. Finish in OSGKeyboard and retry.";
/* Locale chip (short labels) */
"locale.chip.auto" = "Auto";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "Translate";
/* Translation menu */
"keyboard.translation.offMenu" = "Don't translate";
"keyboard.translation.off" = "Don't translate";
"keyboard.translation.enable" = "Enable translation";
"keyboard.translation.disable" = "Disable translation";
"keyboard.translation.a11y" = "Translation";
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
"keyboard.clipboard.a11y" = "Clipboard";
@@ -208,6 +202,9 @@
"keyboard.clipboard.panel.delete" = "Delete";
"keyboard.clipboard.panel.close" = "Close clipboard";
"keyboard.clipboard.panel.closeHint" = "Return to the keyboard.";
"keyboard.clipboard.clear.title" = "Clear clipboard history?";
"keyboard.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
"keyboard.clipboard.clear.confirm" = "Clear history";
"keyboard.clipboard.suggestion.dismissA11y" = "Dismiss clipboard suggestion";
"keyboard.clipboard.suggestion.dismissHint" = "Hide this clipboard suggestion strip.";
"keyboard.voice.cancel" = "Cancel voice input";
@@ -276,6 +273,7 @@
/* AI question mode */
"keyboard.ai.placeholder" = "Tap the microphone to ask AI";
"keyboard.ai.hintA11yPrefix" = "Suggestion: ";
"keyboard.ai.hint" = "Insert the AI answer, then tap Send";
"keyboard.ai.listening" = "Listening…";
"keyboard.ai.recognizing" = "Recognizing your question…";
@@ -295,3 +293,4 @@
"keyboard.ai.error.startTimeout" = "Microphone startup timed out. Try again";
"keyboard.ai.error.requestTimeout" = "AI response timed out. Try again";
"keyboard.ai.error.requestFailed" = "AI response failed. Try again";
"keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again";
+14 -15
View File
@@ -114,13 +114,19 @@
"preview.localeChip.cycle" = "切换识别语言";
/* Keyboard (ext) */
"keyboard.tab.ai" = "AI";
"keyboard.tab.voice" = "语音";
"keyboard.tab.chinese" = "中文";
"keyboard.tab.english" = "EN";
"keyboard.tab.ai.a11y" = "切换到 AI 问答";
"keyboard.tab.voice.a11y" = "切换到语音输入";
"keyboard.tab.chinese.a11y" = "切换到中文输入";
"keyboard.tab.english.a11y" = "切换到英文输入";
"keyboard.placeholder.idle" = "点按说话";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.preparingRecording" = "准备录音…";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";
"keyboard.placeholder.cloudBadge" = "云端";
"keyboard.models.notDownloaded" = "本地模型尚未下载";
"keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型";
"keyboard.rec" = "REC";
@@ -183,20 +189,8 @@
"keyboard.dictation.failed" = "录音失败,请重试";
"keyboard.dictation.resultTimeout" = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试";
/* Locale chip (short labels) */
"locale.chip.auto" = "自动";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Translation chip (v0.3) */
"keyboard.translation.chip" = "翻译";
/* 翻译菜单 */
"keyboard.translation.offMenu" = "不翻译";
"keyboard.translation.off" = "不翻译";
"keyboard.translation.enable" = "开启翻译";
"keyboard.translation.disable" = "关闭翻译";
"keyboard.translation.a11y" = "翻译";
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
"keyboard.clipboard.a11y" = "剪贴板";
@@ -208,6 +202,9 @@
"keyboard.clipboard.panel.delete" = "删除";
"keyboard.clipboard.panel.close" = "关闭剪贴板";
"keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。";
"keyboard.clipboard.clear.title" = "清空剪贴板历史?";
"keyboard.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
"keyboard.clipboard.clear.confirm" = "清空历史";
"keyboard.clipboard.suggestion.dismissA11y" = "关闭剪贴板建议";
"keyboard.clipboard.suggestion.dismissHint" = "隐藏本次剪贴板建议条。";
"keyboard.voice.cancel" = "取消本次语音输入";
@@ -276,6 +273,7 @@
/* AI 问答模式 */
"keyboard.ai.placeholder" = "点击麦克风向 AI 提问";
"keyboard.ai.hintA11yPrefix" = "建议:";
"keyboard.ai.hint" = "先插入 AI 回答,再按发送";
"keyboard.ai.listening" = "正在聆听…";
"keyboard.ai.recognizing" = "正在识别问题…";
@@ -295,3 +293,4 @@
"keyboard.ai.error.startTimeout" = "麦克风启动超时,请重试";
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
@@ -0,0 +1,223 @@
// ClipboardSuggestionLifecycleTests.swift
// OSGKeyboard · Keyboard Extension Tests
//
// Verifies that the transient suggestion belongs to one pasteboard generation
// and one keyboard presentation; clipboard history remains independently usable.
import Combine
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class ClipboardSuggestionLifecycleTests: XCTestCase {
private var suiteName = ""
private var defaults: UserDefaults!
private var state: KeyboardState!
private var history: ClipboardHistoryStore!
private var pasteboard: FakeClipboardPasteboard!
private var coordinator: ClipboardCaptureCoordinator!
override func setUp() {
super.setUp()
suiteName = "ClipboardSuggestionLifecycleTests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)
state = KeyboardState()
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
history = ClipboardHistoryStore(defaults: defaults)
history.lastObservedChangeCount = 10
pasteboard = FakeClipboardPasteboard(changeCount: 10)
coordinator = ClipboardCaptureCoordinator(
state: state,
history: history,
pasteboard: pasteboard
)
coordinator.configure(isSecure: { false }, hasFullAccess: { true })
}
override func tearDown() {
coordinator.keyboardWillDisappear()
coordinator = nil
pasteboard = nil
history = nil
state = nil
defaults.removePersistentDomain(forName: suiteName)
defaults = nil
super.tearDown()
}
func testNewAcceptedCopyShowsSuggestion() {
copy("a fresh sentence")
coordinator.captureIfNeeded()
XCTAssertEqual(state.clipboardSuggestionText, "a fresh sentence")
XCTAssertEqual(state.clipboardSuggestionChangeCount, 11)
}
func testTypingEndsSuggestionForSameGeneration() {
copy("meeting notes")
coordinator.captureIfNeeded()
coordinator.noteUserDidInputText()
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testKeyboardDismissalEndsSuggestionForSameGeneration() {
copy("shipping address")
coordinator.captureIfNeeded()
coordinator.keyboardWillDisappear()
coordinator.keyboardDidAppear()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testKeyboardAppearancePrimesPasteAccessWithoutRepublishingCurrentGeneration() {
pasteboard.setText("already observed", changeCount: 10)
coordinator.keyboardDidAppear()
XCTAssertEqual(pasteboard.stringReadCount, 1)
XCTAssertTrue(history.entries.isEmpty)
XCTAssertNil(state.clipboardSuggestionText)
}
func testRejectedNewCopyClearsInsteadOfReplayingHistory() {
copy("draft reply")
coordinator.captureIfNeeded()
copy("482913")
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["draft reply"])
XCTAssertEqual(history.lastObservedChangeCount, 12)
}
func testNonTextGenerationClearsInsteadOfReplayingHistory() {
copy("draft reply")
coordinator.captureIfNeeded()
pasteboard.setNonText(changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["draft reply"])
XCTAssertEqual(history.lastObservedChangeCount, 12)
}
func testEnablingCandidateBarDoesNotReplayHistory() {
state.clipboardCandidateBarEnabled = false
history.ingest(rawText: "old history item", changeCount: 9)
state.clipboardCandidateBarEnabled = true
coordinator.refreshFlagsFromStore()
XCTAssertNil(state.clipboardSuggestionText)
}
func testDeletingCurrentEntryDoesNotPromoteOlderHistory() throws {
history.ingest(rawText: "older item", changeCount: 9)
copy("current item")
coordinator.captureIfNeeded()
let currentID = try XCTUnwrap(history.newestEntry?.id)
coordinator.deleteEntry(id: currentID)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.map(\.text), ["older item"])
}
func testUnchangedPollingDoesNotRepublishSuggestion() {
copy("stable clipboard")
coordinator.captureIfNeeded()
var publications = 0
let cancellable = state.$clipboardSuggestionText
.dropFirst()
.sink { _ in publications += 1 }
defer { cancellable.cancel() }
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
coordinator.captureIfNeeded()
XCTAssertEqual(publications, 0)
XCTAssertEqual(pasteboard.stringReadCount, 1)
}
func testClearHistoryEndsSuggestionBeforeRemovingEntries() {
history.ingest(rawText: "older item", changeCount: 9)
copy("current item")
coordinator.captureIfNeeded()
coordinator.clearHistory()
XCTAssertTrue(history.entries.isEmpty)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertNil(state.clipboardSuggestionChangeCount)
XCTAssertEqual(history.suggestionDismissedChangeCount, 11)
}
func testExplicitDismissAndInsertRemainTerminal() {
copy("dismiss once")
coordinator.captureIfNeeded()
coordinator.dismissSuggestion()
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
copy("insert once")
coordinator.captureIfNeeded()
coordinator.insertText("insert once", via: { _ in })
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
}
func testSecureFieldTransitionEndsSuggestion() {
copy("private note")
coordinator.captureIfNeeded()
coordinator.secureEntryDidChange(isSecure: true)
coordinator.secureEntryDidChange(isSecure: false)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
}
private func copy(_ text: String) {
pasteboard.setText(text, changeCount: pasteboard.changeCount + 1)
}
}
@MainActor
private final class FakeClipboardPasteboard: ClipboardPasteboardProviding {
private(set) var changeCount: Int
private(set) var hasStrings = false
private var storedString: String?
private(set) var stringReadCount = 0
var string: String? {
stringReadCount += 1
return storedString
}
init(changeCount: Int) {
self.changeCount = changeCount
}
func setText(_ text: String, changeCount: Int) {
self.changeCount = changeCount
hasStrings = true
storedString = text
}
func setNonText(changeCount: Int) {
self.changeCount = changeCount
hasStrings = false
storedString = nil
}
}
@@ -0,0 +1,213 @@
// EditHintSchedulerTests.swift
// OSGKeyboard · Keyboard Extension Tests
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class EditHintSchedulerTests: XCTestCase {
func testShowPublishesImmediately() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Unavailable", isPositive: false, duration: .seconds(2.5))
XCTAssertEqual(state.editHint, "Unavailable")
XCTAssertFalse(state.editHintIsPositive)
await sleeper.waitForRequestCount(1)
scheduler.invalidate()
sleeper.resumeAll()
await drainTasks()
}
func testForwardsErrorAndPositiveDurations() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Failure", isPositive: false, duration: .milliseconds(2_500))
await sleeper.waitForRequestCount(1)
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(2)
XCTAssertEqual(sleeper.recordedDurations, [.milliseconds(2_500), .seconds(10)])
scheduler.invalidate()
sleeper.resumeAll()
await drainTasks()
}
func testExpirationClearsHint() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(1)
sleeper.resumeFirst()
await drainTasks()
XCTAssertNil(state.editHint)
XCTAssertFalse(state.editHintIsPositive)
}
func testSameMessageABADoesNotLetOldExpirationClearNewHint() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Same", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(1)
scheduler.show(message: "Same", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(2)
sleeper.resumeFirst()
await drainTasks()
XCTAssertEqual(state.editHint, "Same")
XCTAssertTrue(state.editHintIsPositive)
sleeper.resumeFirst()
await drainTasks()
XCTAssertNil(state.editHint)
XCTAssertFalse(state.editHintIsPositive)
}
func testOldTaskThatIgnoresCancellationCannotClearNewHint() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Old", isPositive: false, duration: .seconds(2.5))
await sleeper.waitForRequestCount(1)
scheduler.show(message: "New", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(2)
// ControlledSleeper intentionally ignores cancellation and still wakes
// the old task, so generation is the only correctness boundary.
sleeper.resumeFirst()
await drainTasks()
XCTAssertEqual(state.editHint, "New")
XCTAssertTrue(state.editHintIsPositive)
scheduler.invalidate()
sleeper.resumeAll()
await drainTasks()
}
func testClearPositiveDoesNotClearFailure() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Failure", isPositive: false, duration: .seconds(2.5))
await sleeper.waitForRequestCount(1)
scheduler.clearPositive()
XCTAssertEqual(state.editHint, "Failure")
XCTAssertFalse(state.editHintIsPositive)
sleeper.resumeFirst()
await drainTasks()
XCTAssertNil(state.editHint)
XCTAssertFalse(state.editHintIsPositive)
}
func testInvalidateClearsAndPreventsDelayedWriteBack() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
let scheduler = makeScheduler(state: state, sleeper: sleeper)
scheduler.show(message: "Available", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(1)
scheduler.invalidate()
XCTAssertNil(state.editHint)
XCTAssertFalse(state.editHintIsPositive)
sleeper.resumeFirst()
await drainTasks()
XCTAssertNil(state.editHint)
XCTAssertFalse(state.editHintIsPositive)
}
func testReleasedSchedulerDoesNotWriteBack() async {
let state = KeyboardState()
let sleeper = ControlledSleeper()
var scheduler: EditHintScheduler? = makeScheduler(state: state, sleeper: sleeper)
let schedulerReference = WeakSchedulerReference(scheduler)
scheduler?.show(message: "Available", isPositive: true, duration: .seconds(10))
await sleeper.waitForRequestCount(1)
scheduler = nil
XCTAssertNil(schedulerReference.value)
sleeper.resumeFirst()
await drainTasks()
XCTAssertEqual(state.editHint, "Available")
XCTAssertTrue(state.editHintIsPositive)
}
private func makeScheduler(
state: KeyboardState,
sleeper: ControlledSleeper
) -> EditHintScheduler {
EditHintScheduler(
state: state,
sleeper: { duration in
await sleeper.sleep(for: duration)
}
)
}
private func drainTasks() async {
for _ in 0..<3 {
await Task.yield()
}
}
}
@MainActor
private final class WeakSchedulerReference {
weak var value: EditHintScheduler?
init(_ value: EditHintScheduler?) {
self.value = value
}
}
@MainActor
private final class ControlledSleeper {
private(set) var recordedDurations: [Duration] = []
private var continuations: [CheckedContinuation<Void, Never>] = []
func sleep(for duration: Duration) async {
recordedDurations.append(duration)
await withCheckedContinuation { continuation in
continuations.append(continuation)
}
}
func waitForRequestCount(_ count: Int) async {
while recordedDurations.count < count {
await Task.yield()
}
}
func resumeFirst() {
guard !continuations.isEmpty else {
XCTFail("Expected a pending sleep request")
return
}
continuations.removeFirst().resume()
}
func resumeAll() {
let pending = continuations
continuations.removeAll()
pending.forEach { $0.resume() }
}
}
@@ -176,6 +176,111 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertEqual(typing.composition.preedit, "hel")
}
@MainActor
func testOldWordBackspaceRehydratesSuggestionsFromDocumentContext() {
var preceding = "board"
let following = "\n"
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { following }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
typing.synchronizeEnglishDocumentContext(caretMoved: true)
func apply(_ output: TypingOutput) {
for _ in 0..<output.deleteCount {
if !preceding.isEmpty { preceding.removeLast() }
}
preceding += output.text
typing.syncAutocapitalization(
accountingForInsert: output.text,
deleteCount: output.deleteCount
)
}
apply(typing.handleKey(""))
XCTAssertEqual(preceding, "boar")
XCTAssertEqual(typing.composition.preedit, "boar")
apply(typing.handleKey(""))
XCTAssertEqual(preceding, "boa")
XCTAssertEqual(typing.composition.preedit, "boa")
XCTAssertTrue(
typing.composition.candidates.contains {
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
}
)
apply(typing.handleKey("t"))
XCTAssertEqual(preceding, "boat")
XCTAssertEqual(typing.composition.preedit.lowercased(), "boat")
}
@MainActor
func testCandidateReplacementRejectsStaleDocumentAnchor() {
var preceding = ""
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { "" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
for key in ["b", "o", "a"] {
let output = typing.handleKey(key)
preceding += output.text
typing.syncAutocapitalization(accountingForInsert: output.text)
}
guard let boatIndex = typing.composition.candidates.firstIndex(where: {
$0.text.compare("boat", options: .caseInsensitive) == .orderedSame
}) else {
return XCTFail("expected boat completion")
}
preceding = "board"
let output = typing.selectCandidate(at: boatIndex)
XCTAssertEqual(output, .none)
XCTAssertEqual(typing.composition.preedit.lowercased(), "board")
}
@MainActor
func testMidWordCaretSuppressesUnsafeBackwardOnlyReplacement() {
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { "boa" }
typing.followingTextProvider = { "rd" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
typing.synchronizeEnglishDocumentContext(caretMoved: true)
XCTAssertTrue(typing.composition.candidates.isEmpty)
XCTAssertTrue(typing.composition.preedit.isEmpty)
}
@MainActor
func testStaleHostCallbackDoesNotDiscardLocalEnglishWord() {
var preceding = ""
let typing = TypingSessionController()
typing.suggestionsEnabled = true
typing.precedingTextProvider = { preceding }
typing.followingTextProvider = { "" }
_ = typing.setLanguage(.english)
typing.enterTypingMode()
let output = typing.handleKey("h")
typing.syncAutocapitalization(accountingForInsert: output.text)
typing.synchronizeEnglishDocumentContext(caretMoved: true)
XCTAssertEqual(typing.composition.preedit.lowercased(), "h")
preceding = "h"
typing.synchronizeEnglishDocumentContext()
XCTAssertEqual(typing.composition.preedit.lowercased(), "h")
}
@MainActor
func testAutocorrectUndoRestoresOriginal() {
let typing = TypingSessionController()
@@ -118,4 +118,24 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(KeyboardState.InputMode.allCases, [.polish])
XCTAssertEqual(KeyboardState.InputMode(rawValue: "polish"), .polish)
}
func testSecureFieldImmediatelyHidesClipboardUIWithoutRestoringBody() {
let state = KeyboardState()
state.clipboardSuggestionText = "private clipboard body"
state.clipboardSuggestionChangeCount = 7
state.clipboardOverlay = .historyPanel
state.setSecureTextEntry(true)
XCTAssertFalse(state.canShowClipboardEntry)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertNil(state.clipboardSuggestionChangeCount)
XCTAssertEqual(state.clipboardOverlay, .none)
state.setSecureTextEntry(false)
XCTAssertTrue(state.canShowClipboardEntry)
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(state.clipboardOverlay, .none)
}
}
@@ -50,12 +50,12 @@ final class RimeSchemaGeneratorTests: XCTestCase {
let configuration = TypingInputConfiguration(defaults: defaults)
XCTAssertEqual(configuration.schema, .fullPinyin)
XCTAssertTrue(configuration.fuzzyPairs.isEmpty)
XCTAssertFalse(configuration.defaultToTyping)
XCTAssertEqual(configuration.defaultInputMode, .voice)
XCTAssertFalse(configuration.rememberLastSurface)
}
@MainActor
func testDefaultTypingPreferencePersistsAndDefaultsToOff() {
func testDefaultInputModePersistsAndDefaultsToVoice() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
@@ -65,12 +65,38 @@ final class RimeSchemaGeneratorTests: XCTestCase {
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.voice
)
XCTAssertNil(TypingInputConfiguration.preferredTypingLanguageOnOpen(defaults: defaults))
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
XCTAssertTrue(TypingInputConfiguration.prefersTypingOnOpen(defaults: defaults))
XCTAssertTrue(TypingInputConfiguration(defaults: defaults).defaultToTyping)
XCTAssertEqual(TypingInputConfiguration(defaults: defaults).defaultInputMode, .pinyin)
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).surface,
.typing
)
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).typingLanguage,
.chinese
)
configuration.defaultInputMode = .english
XCTAssertEqual(
TypingInputConfiguration.preferredOpenPreference(defaults: defaults).typingLanguage,
.english
)
}
@MainActor
func testLegacyDefaultToTypingMigratesToPinyin() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: "typing.input.defaultToTyping")
let configuration = TypingInputConfiguration(defaults: defaults)
XCTAssertEqual(configuration.defaultInputMode, .pinyin)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
@@ -78,13 +104,13 @@ final class RimeSchemaGeneratorTests: XCTestCase {
}
@MainActor
func testRememberLastSurfaceOverridesDefaultToTyping() {
func testRememberLastSurfaceOverridesDefaultInputMode() {
let suiteName = "TypingInputConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
configuration.rememberLastSurface = true
TypingInputConfiguration.persistLastSurface(.voice, defaults: defaults)
@@ -94,10 +120,10 @@ final class RimeSchemaGeneratorTests: XCTestCase {
)
TypingInputConfiguration.persistLastSurface(.typing, defaults: defaults)
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
TypingInputConfiguration.persistLastTypingLanguage(.english, defaults: defaults)
let preference = TypingInputConfiguration.preferredOpenPreference(defaults: defaults)
XCTAssertEqual(preference.surface, .typing)
XCTAssertEqual(preference.typingLanguage, .english)
}
@MainActor
@@ -107,13 +133,17 @@ final class RimeSchemaGeneratorTests: XCTestCase {
defer { defaults.removePersistentDomain(forName: suiteName) }
let configuration = TypingInputConfiguration(defaults: defaults)
configuration.defaultToTyping = true
configuration.defaultInputMode = .pinyin
configuration.rememberLastSurface = true
XCTAssertEqual(
TypingInputConfiguration.preferredSurfaceOnOpen(defaults: defaults),
.typing
)
XCTAssertEqual(
TypingInputConfiguration.preferredTypingLanguageOnOpen(defaults: defaults),
.chinese
)
}
@MainActor
@@ -1,5 +1,5 @@
// SevenDayUsageChart.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// 7-day dictation bar chart. Platform shells wrap this in their own page
// layout; the chart itself only needs points + UI language.
@@ -1,5 +1,5 @@
// SupportDeveloperSection.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Optional voluntary tip block for Settings. Does not gate features.
@@ -1,29 +1,32 @@
// UsageStatsCluster.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics.
// Callers observe their store and pass plain values Shared stays unbound
// from platform singletons.
//
// Optional `header` sits above the 7-day chart inside the same surface card
// (iOS Home glass preview field). Mac / plain call sites keep `EmptyView`.
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public struct UsageStatsCluster: View {
@Environment(\.themePalette) private var palette
public enum Layout: Sendable, Equatable {
public enum UsageStatsClusterLayout: Sendable, Equatable {
/// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad).
case split
/// Chart above a compact single-card 2×2 grid (iPhone).
case stacked
}
/// 2×2 沿 HomeStatsCard
static let compactGridHeight: CGFloat = 166
public static let compactGridHeight: CGFloat = 166
}
public let layout: Layout
public struct UsageStatsCluster<Header: View>: View {
@Environment(\.themePalette) private var palette
public let layout: UsageStatsClusterLayout
public let language: AppUILanguage
public let points: [UsageStatisticsStore.DailyUsagePoint]
public let dictationCharacterCount: Int
@@ -32,16 +35,18 @@ public struct UsageStatsCluster: View {
public let dictionaryTermCount: Int
/// iPhone SE stacked
public let compact: Bool
private let header: Header
public init(
layout: Layout,
layout: UsageStatsClusterLayout,
language: AppUILanguage,
points: [UsageStatisticsStore.DailyUsagePoint],
dictationCharacterCount: Int,
dictationDurationSeconds: TimeInterval,
translationCharacterCount: Int,
dictionaryTermCount: Int,
compact: Bool = false
compact: Bool = false,
@ViewBuilder header: () -> Header
) {
self.layout = layout
self.language = language
@@ -51,6 +56,7 @@ public struct UsageStatsCluster: View {
self.translationCharacterCount = translationCharacterCount
self.dictionaryTermCount = dictionaryTermCount
self.compact = compact
self.header = header()
}
public var body: some View {
@@ -66,7 +72,7 @@ public struct UsageStatsCluster: View {
private var splitBody: some View {
HStack(alignment: .top, spacing: Spacing.md) {
SevenDayUsageChart(points: points, language: language)
chartCard
.frame(maxWidth: .infinity, maxHeight: .infinity)
splitStatGrid
.frame(maxWidth: .infinity)
@@ -112,13 +118,34 @@ public struct UsageStatsCluster: View {
private var stackedBody: some View {
VStack(spacing: compact ? Spacing.sm : Spacing.md) {
chartCard
compactStatGrid
}
}
/// Chart surface; when `header` is present it sits above the bars in the same card.
@ViewBuilder
private var chartCard: some View {
if Header.self == EmptyView.self {
SevenDayUsageChart(
points: points,
language: language,
chartMinHeight: compact ? 72 : 96,
expands: false
expands: layout == .split
)
compactStatGrid
} else {
UsageSurfaceCard(padding: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
SevenDayUsageChart(
points: points,
language: language,
chartMinHeight: compact ? 72 : 96,
embedsInCard: false,
expands: layout == .split
)
}
}
}
}
@@ -156,7 +183,7 @@ public struct UsageStatsCluster: View {
}
}
// HomeStatsCard 166pt
.frame(height: UsageStatsCluster.compactGridHeight)
.frame(height: UsageStatsClusterLayout.compactGridHeight)
.background(palette.surface)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
@@ -197,3 +224,28 @@ public struct UsageStatsCluster: View {
.padding(Spacing.md)
}
}
extension UsageStatsCluster where Header == EmptyView {
public init(
layout: UsageStatsClusterLayout,
language: AppUILanguage,
points: [UsageStatisticsStore.DailyUsagePoint],
dictationCharacterCount: Int,
dictationDurationSeconds: TimeInterval,
translationCharacterCount: Int,
dictionaryTermCount: Int,
compact: Bool = false
) {
self.init(
layout: layout,
language: language,
points: points,
dictationCharacterCount: dictationCharacterCount,
dictationDurationSeconds: dictationDurationSeconds,
translationCharacterCount: translationCharacterCount,
dictionaryTermCount: dictionaryTermCount,
compact: compact,
header: { EmptyView() }
)
}
}
@@ -1,5 +1,5 @@
// ASRChunkTranscribing.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Minimal ASR surface for pipelined utterance chunking. Keeps
// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`.
@@ -1,18 +1,13 @@
// ASRService.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Speech-to-text abstraction. As of iOS 26 being the minimum
// deployment target, the only ASR backend is `SpeechAnalyzer` +
// `DictationTranscriber` always on-device, no cloud fallback, no
// `requiresOnDevice` toggle. The previous legacy recognizer path is
// gone; if a future platform ever needs it back,
// reintroduce as a sibling class in `ASRServiceFactory.make()`.
// Speech-to-text abstraction for foreground host flows. Local mode uses
// iOS 26 `SpeechAnalyzer` + `DictationTranscriber`; cloud mode uses the
// provider selected in the user's configuration.
//
// Lives in `OSGKeyboardShared` (not the keyboard extension target) so
// that the host app's `KeyboardPreviewSheet` can run the same ASR
// pipeline against real iOS audio without it, the in-app preview
// was a static mock that never actually called `SFSpeechRecognizer`,
// and "did you actually wire up ASR?" was a fair review note.
// Lives in `OSGKeyboardHostSupport` because the foreground host owns
// audio capture and recognition. The keyboard extension receives
// completed results through the Flow bridge instead of running ASR.
import Foundation
import AVFoundation
@@ -36,9 +31,8 @@ extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
public protocol ASRService: ASRChunkTranscribing, Sendable {
/// Start a transcription session. The returned stream emits `.partial`
/// updates and exactly one `.final` (or `.error`) before finishing.
/// `SpeechAnalyzer` is always fully on-device, so there is no
/// `requiresOnDevice` flag that legacy cloud-fallback control
/// doesn't apply to the iOS 26 `SpeechAnalyzer` path.
/// The local `SpeechAnalyzer` path is fully on-device, so this
/// abstraction does not expose the legacy `requiresOnDevice` flag.
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
@@ -385,8 +379,8 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
}
}
/// Canonical capture format: 16 kHz mono Float32 from `AudioCaptureService`
/// / `PreviewASRController` before it reaches SpeechAnalyzer.
/// Canonical 16 kHz mono Float32 format produced by the host capture
/// pipelines before samples reach SpeechAnalyzer.
private static let captureFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
@@ -1,5 +1,5 @@
// ChunkedUtterancePipeline.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Pipelined Flow utterance ASR: split PCM while recording, transcribe chunks
// serially on a background queue, stitch partials for display and delivery.
@@ -336,7 +336,8 @@ public actor ChunkedUtterancePipeline {
FlowTrace.warn(
"pipeline.chunk.retry",
"chunk=\(chunkIndex) samples=\(samples.count) error=\(message)"
"chunk=\(chunkIndex) samples=\(samples.count) "
+ "errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
do {
try await Task.sleep(nanoseconds: 150_000_000)
@@ -362,7 +363,10 @@ public actor ChunkedUtterancePipeline {
FlowTrace.transcript("asr.chunk", trimmed, audio)
}
case .failure(let message):
FlowTrace.warn("pipeline.chunk.failed", "\(audio) error=\(message)")
FlowTrace.warn(
"pipeline.chunk.failed",
"\(audio) errorCategory=asrFailure errorBytes=\(message.utf8.count)"
)
case .cancelled:
FlowTrace.pipeline("chunk.cancelled", audio)
}
@@ -1,5 +1,5 @@
// AlibabaVocabularySync.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Syncs PersonalDictionary DashScope custom vocabulary (Fun-ASR Flash).
@@ -1,5 +1,5 @@
// BailianRealtimeASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference
// WebSocket (`/api-ws/v1/inference`). Utterance-level duplex session with
@@ -145,6 +145,8 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.string(text))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -153,6 +155,8 @@ struct BailianRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable
static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws {
do {
try await task.send(.data(data))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// CloudASRClients.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Provider-specific cloud ASR backends with personal-dictionary bias.
@@ -1,5 +1,5 @@
// CloudASRConnectionCheck.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Settings "validate connection" probe shared by iOS and macOS.
@@ -1,5 +1,5 @@
// CloudASRService.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Cloud-engine ASR: uploads PCM to the user's configured provider with
// personal-dictionary bias. Streaming-capable providers use one utterance
@@ -11,6 +11,10 @@ import os
import OSGKeyboardShared
#endif
/// Uploads PCM only on the user-selected cloud engine path; provider clients
/// reject missing credentials before network transmission. Personal dictionary
/// entries are sent as recognition bias. Mutable client/cancellation state is
/// lock-protected, which is the basis for `@unchecked Sendable`.
public final class CloudASRService: ASRService, @unchecked Sendable {
private let store: any ConfigurationStore
private let session: URLSession
@@ -56,7 +60,9 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
do {
try await client.prepare(dictionary: store.personalDictionary)
} catch {
OSGLog.asr.warning("cloud ASR vocabulary prepare failed: \(error.localizedDescription, privacy: .public)")
OSGLog.asr.warning(
"cloud ASR vocabulary prepare failed: \(CloudASRLogMetadata.describe(error), privacy: .public)"
)
}
}
@@ -89,7 +95,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
+ "rms=\(FlowTrace.rms(samples)) elapsed=\(FlowTrace.seconds(since: startedAt))s"
)
return trimmed.isEmpty ? .success("") : .success(trimmed)
} catch is CancellationError {
} catch where ProviderToolCancellation.matches(error) {
FlowTrace.asr("cloud.chunk.cancelled", "samples=\(samples.count)")
return .cancelled
} catch {
@@ -97,7 +103,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
"asr.cloud.chunk.failed",
"provider=\(store.asrProviderId) samples=\(samples.count) "
+ "rms=\(FlowTrace.rms(samples)) elapsed=\(FlowTrace.seconds(since: startedAt))s "
+ "error=\(error.localizedDescription)"
+ "\(CloudASRLogMetadata.describe(error))"
)
return .failure(error.localizedDescription)
}
@@ -129,9 +135,11 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
dictionary: store.personalDictionary,
onPartial: onPartial
)
} catch where ProviderToolCancellation.matches(error) {
return .cancelled
} catch {
OSGLog.asr.warning(
"streaming ASR session open failed, using chunked batch: \(error.localizedDescription, privacy: .public)"
"streaming ASR session open failed, using chunked batch: \(CloudASRLogMetadata.describe(error), privacy: .public)"
)
let pipeline = ChunkedUtterancePipeline(asr: self, locale: locale)
return await pipeline.transcribe(stream: stream, onPartial: onPartial)
@@ -1,5 +1,5 @@
// CloudASRStreaming.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Utterance-scoped cloud ASR sessions: one long-lived connection per press,
// streaming PCM up and interim text down. Chunked batch ASR remains the
@@ -10,6 +10,35 @@ import Foundation
import OSGKeyboardShared
#endif
enum CloudASRLogMetadata {
static func describe(_ error: Error) -> String {
if let cloudError = error as? CloudASRError {
switch cloudError {
case .noAPIKey:
return "category=noAPIKey"
case .invalidURL:
return "category=invalidURL"
case .http(let status, let message):
return "category=http status=\(status) detailBytes=\(message?.utf8.count ?? 0)"
case .decoding(let detail):
return "category=decoding detailBytes=\(detail.utf8.count)"
case .transport(let detail):
return "category=transport detailBytes=\(detail.utf8.count)"
case .emptyTranscript:
return "category=emptyTranscript"
case .audioTooLong:
return "category=audioTooLong"
case .providerUnsupported:
return "category=providerUnsupported"
}
}
if let urlError = error as? URLError {
return "category=url code=\(urlError.code.rawValue)"
}
return "category=\(String(reflecting: type(of: error)))"
}
}
/// Long-lived cloud ASR session for one Flow utterance.
public protocol CloudASRStreamingSession: Sendable {
/// Append 16 kHz mono Float32 PCM captured while the mic is open.
@@ -125,7 +154,7 @@ public actor StreamingUtterancePipeline {
+ "elapsed=\(FlowTrace.seconds(since: startedAt))s"
)
return .success(ChunkedUtteranceSuccess(text: finalText))
} catch is CancellationError {
} catch where ProviderToolCancellation.matches(error) {
activeSession?.cancel()
activeSession = nil
FlowTrace.asr("cloud.stream.cancelled", "uploadedSamples=\(uploadedSamples)")
@@ -138,7 +167,7 @@ public actor StreamingUtterancePipeline {
"asr.cloud.stream.failed",
"uploadedSamples=\(uploadedSamples) "
+ "elapsed=\(FlowTrace.seconds(since: startedAt))s "
+ "error=\(error.localizedDescription)"
+ "\(CloudASRLogMetadata.describe(error))"
)
return .failure(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// OpenAIRealtimeASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// OpenAI Realtime transcription (WebSocket). Streams PCM and transcript
// deltas for utterance-level ASR. Batch `/audio/transcriptions` remains the
@@ -94,10 +94,17 @@ struct OpenAIRealtimeASRClient: CloudASRTranscribing, CloudASRStreamingCapable {
)
session.cancel()
} catch {
guard Self.shouldFallbackToBatch(afterProbeError: error) else {
throw CancellationError()
}
try await batchClient.probeConnection()
}
}
static func shouldFallbackToBatch(afterProbeError error: Error) -> Bool {
!ProviderToolCancellation.matches(error)
}
private var resolvedRealtimeModel: String {
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed.hasPrefix("gpt-4o") || trimmed == "whisper-1" {
@@ -326,6 +333,8 @@ private final class OpenAIRealtimeStreamingSession: CloudASRStreamingSession, @u
}
do {
try await wsTask.send(.string(string))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -1,5 +1,5 @@
// VolcengineCloudASRClient.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Volcengine SAUC bigmodel ASR client. Utterance-level WebSocket session with
// enable_nonstream (official two-pass): interim text for on-screen partials,
@@ -363,9 +363,12 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
guard let frame = VolcengineFrame.parse(data) else { continue }
if frame.messageType == .errorMessage {
let body = String(data: frame.payload, encoding: .utf8) ?? ""
let code = frame.errorCode ?? 0
publishFailure(CloudASRError.transport("ASR error \(code): \(body)"))
publishFailure(
CloudASRError.transport(
"ASR error code=\(code) responseBytes=\(frame.payload.count)"
)
)
return
}
guard frame.messageType == .fullServerResponse else { continue }
@@ -397,6 +400,8 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
private func send(_ data: Data) async throws {
do {
try await wsTask.send(.data(data))
} catch where ProviderToolCancellation.matches(error) {
throw CancellationError()
} catch {
throw CloudASRError.transport(error.localizedDescription)
}
@@ -1,10 +1,10 @@
// CustomLanguageModelManager.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Prepares the bundled SFCustomLanguageModelData asset on device and shares
// the compiled LM + Vocab through the App Group container. Both the host app
// and keyboard extension read the same prepared configuration for
// DictationTranscriber content hints.
// Prepares the bundled SFCustomLanguageModelData asset for the host app's
// iOS SpeechAnalyzer pipeline and caches the compiled LM + Vocab in the
// App Group container. The keyboard extension does not run ASR or load
// these assets.
import Foundation
import Speech
@@ -250,8 +250,8 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
// MARK: - Bundle / disk helpers
private static var resourceBundle: Bundle {
// CLM assets ship in the host app bundle (not the extension Shared
// framework) so the keyboard process never mmaps the training bin.
// CLM assets ship in the host app bundle, so the keyboard process
// never mmaps the training bin.
Bundle.main
}
@@ -7,7 +7,9 @@
import AVFoundation
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum FlowAudioRouteRecoveryPolicy {
public static func shouldRebuild(
@@ -41,6 +43,9 @@ public final class FlowAudioEngineHandle: @unchecked Sendable {
}
}
/// Process-wide owner of AVAudioSession and Flow's audio engines. Its serial
/// queue orders every category, activation, route snapshot, and engine
/// start/stop; that queue confinement justifies `@unchecked Sendable`.
public final class FlowAudioSessionCoordinator: @unchecked Sendable {
private struct CaptureActivation: Sendable {
let snapshot: FlowAudioSessionSnapshot
@@ -9,7 +9,9 @@
import AVFoundation
import Foundation
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum FlowCaptureVoiceProcessing {
@@ -1,5 +1,5 @@
// FlowContinuousCapture.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// TypeWhisper-style continuous mic capture for Flow sessions: one
// AVAudioEngine + input tap for the entire session. Utterances gate
@@ -413,6 +413,9 @@ private final class AdaptiveDownsampler: @unchecked Sendable {
}
}
/// Owns the session-long capture graph on the main actor. The realtime tap
/// must not touch UserDefaults, log, or invoke actor callbacks; it exchanges
/// snapshots through lock-protected relays, and callbacks return on MainActor.
@MainActor
public final class FlowContinuousCapture {
@@ -1,40 +1,27 @@
// LiveDictationController.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Unified on-device dictation session: mic capture + iOS 26 SpeechAnalyzer.
// Used by the keyboard preview sheet, host-app dictation handoff, and any
// other foreground surface that needs live ASR without duplicating pipeline code.
// Retained for foreground preview and one-shot handoff surfaces that need
// live ASR without duplicating the host-owned audio pipeline.
//
// STATUS (v0.1.2): Retained as a "preview / one-shot handoff" path.
// The *primary* voice-session path is `FlowSessionManager` +
// `FlowContinuousCapture` (TypeWhisper-style continuous capture shared
// between host app and keyboard extension). The keyboard extension
// consumes results through `FlowSessionBridge`.
// `FlowContinuousCapture`; the keyboard extension consumes its results
// through `FlowSessionBridge`.
//
// This class is still imported by:
// - `OSGKeyboard/Views/PreviewASRController.swift` (typealias)
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (in-app preview)
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (host-app ASR preview)
// - `OSGKeyboardTests/PreviewASRControllerStateTests.swift`
//
// Do NOT remove without updating those call sites. The earlier
// `OSGKeyboardExt/Services/AudioCaptureService.swift` *was* a true
// dead duplicate and has been deleted (see AUDIT_APPSTORE.md P0-3).
// Do NOT remove without updating those call sites.
// Owns its own AVAudioEngine + AVAudioSession, downsamples to 16 kHz
// mono Float32 on the audio thread (same as `AudioCaptureService`), and
// feeds `AudioBufferSnapshot` to the shared `ASRService` (the same
// pipeline the real keyboard extension
// uses, so the preview exercises the *real* iOS speech APIs, not a
// stub). Without this the in-app preview was a hardcoded transcript
// and "did you actually call SFSpeechRecognizer?" was a fair review
// note.
// mono Float32 on the audio thread, and feeds `AudioBufferSnapshot` to
// the HostSupport `ASRService`, so previews exercise the real iOS
// speech APIs instead of a stub.
//
// Why not reuse `AudioCaptureService` from the extension? It lives in
// `OSGKeyboardExt`, an `app-extension` target the main app can't
// import its symbols. We could move it to `OSGKeyboardShared`, but
// `AVAudioSession` lifecycle differs enough between a keyboard
// extension (no background, no recording entitlement surprise) and a
// foreground app that a copy here is the lesser evil.
// This stays in HostSupport because foreground `AVAudioSession`
// lifecycle and recording ownership do not belong in the keyboard
// extension or the platform-neutral Shared target.
import Foundation
import AVFoundation
@@ -411,7 +398,7 @@ public final class LiveDictationController: ObservableObject {
controller.phase = .idle
}
case .failure(let message):
controller.debug("asr error: \(message)")
controller.debug("asr failed messageLen=\(message.count)")
controller.teardownCapturePipeline()
controller.errorMessage = message
controller.phase = .error(message)
@@ -442,7 +429,7 @@ public final class LiveDictationController: ObservableObject {
// `requestAuthorization` callback were re-typed in the
// `@MainActor` context of the caller, and the runtime
// assertion came right back same crash, different symbol:
// `closure #1 in closure #2 in PreviewASRController.start(locale:)`.
// `closure #1 in closure #2 in LiveDictationController.start(locale:)`.
//
// The fix that survives inlining is the *function-reference*
// pattern, the same one used for `installTap` in
@@ -526,8 +513,8 @@ public final class LiveDictationController: ObservableObject {
let meter = min(Double(rms) * 4.0, 1.0)
onMeter(meter)
// 2) Downsample to 16 kHz mono Float32 for ASR (matches
// `AudioCaptureService` and Apple's `considering:` hint).
// 2) Downsample to the 16 kHz mono Float32 format expected by
// HostSupport ASR and Apple's `considering:` hint.
let outFrames = AVAudioFrameCount(
Double(buffer.frameLength) * targetFormat.sampleRate / hwFormat.sampleRate
)
@@ -1,5 +1,5 @@
// TipPurchaseManager.swift
// OSGKeyboard · Shared
// OSGKeyboard · HostSupport
//
// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only
// no feature gates, no App Group sync, no restore (Apple consumable rules).
-35
View File
@@ -32,13 +32,9 @@ enum MacMetrics {
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
/// between rows and `.padding(.vertical:)` on the card body.
static let settingsRowGap: CGFloat = Spacing.md
/// Provider menu trigger width (legacy).
static let selectWidth: CGFloat = 200
/// Text-field max width for provider credential rows (narrower than the
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
static let fieldWidth: CGFloat = 280
/// Legacy alias used by older call sites; prefer `fieldWidth`.
static let controlWidth: CGFloat = fieldWidth
/// Horizontal inset inside settings cards matches History / Dictionary rows.
static let settingsCardInset: CGFloat = Spacing.md
/// Below this row width, provider rows stack label above control.
@@ -266,19 +262,6 @@ struct MacInlineRow<Control: View>: View {
}
}
/// Backwards-compatible alias: title + optional subtitle + trailing control.
struct MacFormSubtitleRow<Control: View>: View {
let title: String
var subtitle: String? = nil
@ViewBuilder var control: () -> Control
var body: some View {
MacInlineRow(title: title, subtitle: subtitle) {
control()
}
}
}
/// Tappable navigation / action row inside a settings card.
struct MacFormLinkRow: View {
@Environment(\.themePalette) private var palette
@@ -324,8 +307,6 @@ struct MacProviderSettingRow<Content: View>: View {
let title: String
var subtitle: String? = nil
/// Retained for source compatibility; the control now fills its column.
var controlMaxWidth: CGFloat? = nil
/// Cross-axis alignment between the label column and the control. Provider
/// rows keep `.top` (model row grows a status line below its field); single
/// control rows can pass `.center` to vertically center label and control.
@@ -431,22 +412,6 @@ struct MacSettingsToolButton: View {
}
}
// MARK: - Legacy setting row
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
/// for provider configuration cards.
struct MacSettingRow<Content: View>: View {
let title: String
var controlMaxWidth: CGFloat? = nil
@ViewBuilder var content: () -> Content
var body: some View {
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
content()
}
}
}
// MARK: - Page header
/// Capsule-shaped primary action aligned with page-header search controls.
+16 -4
View File
@@ -88,19 +88,24 @@ struct MacSettingsICloudSyncRow: View {
do {
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
} catch let error as PersonalDictionaryCloudSyncError {
MacICloudSyncBootstrap.settingsSync.disableSync()
do {
try MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
if case .payloadTooLarge = error {
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
} else {
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
} catch {
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
return
}
reloadFromStore()
} catch {
isEnabled = false
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
@@ -108,9 +113,16 @@ struct MacSettingsICloudSyncRow: View {
}
private func disableSync() {
MacICloudSyncBootstrap.settingsSync.disableSync()
isEnabled = false
syncErrorMessage = nil
isApplyingToggle = true
do {
try MacICloudSyncBootstrap.settingsSync.disableSync()
reloadFromStore()
} catch {
reloadFromStore()
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
}
isApplyingToggle = false
}
private func syncNow() {
+3 -1
View File
@@ -2,7 +2,9 @@
// OSGKeyboard · Mac
//
// A short first-run setup for the macOS app. It is intentionally separate
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
// from iOS onboarding because Mac needs Accessibility and local-model setup.
// The current local runtime defaults to Qwen3 MLX with Apple Speech fallback;
// Sherpa identifiers and install records are retained only for migration compatibility.
//
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
// glowing hero icon, a large title block, and elongated capsule progress
+118 -26
View File
@@ -272,8 +272,10 @@ struct MacProviderModelRow: View {
let title: String
let placeholder: String
@Binding var model: String
let providerIdentity: String
let endpointIdentity: String
let apiKey: String
let fetchModels: () async throws -> [String]
let makeFetchModelsRequest: @MainActor () -> ProviderToolRequest<[String]>
let language: AppUILanguage
@State private var models: [String] = []
@@ -282,6 +284,7 @@ struct MacProviderModelRow: View {
@State private var failed = false
@State private var isDropdownOpen = false
@State private var fieldWidth: CGFloat = 0
@State private var requestCoordinator = ProviderToolRequestCoordinator()
private let chevronWidth: CGFloat = 28
private let dropdownMaxHeight: CGFloat = 240
@@ -313,7 +316,7 @@ struct MacProviderModelRow: View {
disabled: isRunning || trimmedAPIKey.isEmpty
) {
isDropdownOpen = false
Task { await runFetchModels() }
runFetchModels()
}
if isRunning {
@@ -325,6 +328,11 @@ struct MacProviderModelRow: View {
statusMessage
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: apiKey) { _, _ in invalidateRequest() }
.onChange(of: model) { _, _ in invalidateRequestIfRunning() }
.onDisappear { invalidateRequest() }
}
/// Editable model id + trailing chevron. Model list is a true popover so the
@@ -332,7 +340,7 @@ struct MacProviderModelRow: View {
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
return ZStack(alignment: .trailing) {
TextField(text: $model, prompt: Text(verbatim: placeholder)) {
TextField(text: editableModelBinding, prompt: Text(verbatim: placeholder)) {
Text(title)
}
.labelsHidden()
@@ -400,6 +408,7 @@ struct MacProviderModelRow: View {
private func dropdownRow(_ modelId: String) -> some View {
let isSelected = modelId == model
return Button {
invalidateRequest()
model = modelId
message = MacL10n.format(
"mac.settings.modelSelected",
@@ -445,33 +454,79 @@ struct MacProviderModelRow: View {
}
}
private var editableModelBinding: Binding<String> {
Binding(
get: { model },
set: { newValue in
invalidateRequest()
model = newValue
}
)
}
@MainActor
private func runFetchModels() async {
private func runFetchModels() {
guard !trimmedAPIKey.isEmpty else {
failed = true
message = SharedL10n.string("providerTools.error.missingAPIKey", language: language)
return
}
let request = makeFetchModelsRequest()
let currentModel = model
let currentLanguage = language
let runningMessage = MacL10n.string("mac.settings.loadingModels", language: currentLanguage)
let emptyMessage = SharedL10n.string("providerTools.error.empty", language: currentLanguage)
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runFetchModels(
runningMessage: MacL10n.string("mac.settings.loadingModels", language: language),
loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) },
emptyMessage: SharedL10n.string("providerTools.error.empty", language: language),
currentModel: model,
fetchModels: fetchModels
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runFetchModels(
runningMessage: runningMessage,
loadedMessage: {
MacL10n.format("mac.settings.modelsLoaded", language: currentLanguage, $0)
},
emptyMessage: emptyMessage,
currentModel: currentModel,
fetchModels: request.operation
)
models = outcome.state.models
message = outcome.state.message
failed = outcome.state.failed
if let selected = outcome.selectedModel {
model = selected
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state, let selectedModel):
models = state.models
message = state.message
failed = state.failed
if let selectedModel {
model = selectedModel
}
}
}
)
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
@MainActor
private func invalidateRequestIfRunning() {
guard requestCoordinator.isRunning else { return }
invalidateRequest()
}
}
// MARK: - Connection validate
@@ -479,12 +534,17 @@ struct MacProviderToolsRow: View {
@Environment(\.themePalette) private var palette
let title: String
let validate: () async throws -> Void
let providerIdentity: String
let endpointIdentity: String
let credentialIdentity: String
let modelIdentity: String
let makeValidateRequest: @MainActor () -> ProviderToolRequest<Void>
let language: AppUILanguage
@State private var isRunning = false
@State private var message: String?
@State private var failed = false
@State private var requestCoordinator = ProviderToolRequestCoordinator()
var body: some View {
MacProviderSettingRow(title: title) {
@@ -493,7 +553,7 @@ struct MacProviderToolsRow: View {
title: MacL10n.string("mac.settings.validate", language: language),
disabled: isRunning
) {
Task { await runValidate() }
runValidate()
}
if isRunning {
@@ -508,21 +568,53 @@ struct MacProviderToolsRow: View {
}
}
}
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
.onChange(of: modelIdentity) { _, _ in invalidateRequest() }
.onDisappear { invalidateRequest() }
}
@MainActor
private func runValidate() async {
private func runValidate() {
let request = makeValidateRequest()
let currentLanguage = language
let runningMessage = MacL10n.string("mac.settings.validating", language: currentLanguage)
let successMessage = MacL10n.string("mac.settings.validateSuccess", language: currentLanguage)
isRunning = true
failed = false
defer { isRunning = false }
message = runningMessage
let outcome = await ProviderToolRunner.runValidate(
runningMessage: MacL10n.string("mac.settings.validating", language: language),
successMessage: MacL10n.string("mac.settings.validateSuccess", language: language),
validate: validate
requestCoordinator.start(
providerIdentity: request.providerIdentity,
operation: {
await ProviderToolRunner.runValidate(
runningMessage: runningMessage,
successMessage: successMessage,
validate: request.operation
)
message = outcome.message
failed = outcome.failed
},
commit: { outcome in
isRunning = false
switch outcome {
case .cancelled:
message = nil
failed = false
case .completed(let state):
message = state.message
failed = state.failed
}
}
)
}
@MainActor
private func invalidateRequest() {
requestCoordinator.invalidate()
isRunning = false
message = nil
failed = false
}
}
+62 -32
View File
@@ -131,8 +131,10 @@ struct MacSettingsView: View {
title: MacL10n.string("mac.settings.model", language: lang),
placeholder: currentPolishProvider.defaultModel,
model: $viewModel.config.model,
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
fetchModels: fetchMacLLMModels,
makeFetchModelsRequest: makeMacLLMModelsRequest,
language: lang
)
.id(viewModel.config.providerId)
@@ -143,7 +145,11 @@ struct MacSettingsView: View {
)
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacLLM,
providerIdentity: viewModel.config.providerId,
endpointIdentity: viewModel.config.baseURL,
credentialIdentity: viewModel.config.apiKey,
modelIdentity: viewModel.config.model,
makeValidateRequest: makeMacLLMValidateRequest,
language: lang
)
MacProviderSettingRow(title: MacL10n.string("mac.settings.translation", language: lang)) {
@@ -179,7 +185,11 @@ struct MacSettingsView: View {
}
MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
validate: validateMacASR,
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
credentialIdentity: viewModel.config.asrApiKey,
modelIdentity: viewModel.config.asrModel,
makeValidateRequest: makeMacASRValidateRequest,
language: lang
)
}
@@ -226,14 +236,6 @@ struct MacSettingsView: View {
isSecret: true
)
}
MacProviderNoteRow(
text: MacL10n.string(
macVolcengineFields.usesAPIKeyAuth
? "mac.settings.volcengineNoteApiKey"
: "mac.settings.volcengineNoteAppToken",
language: lang
)
)
}
@ViewBuilder
@@ -256,8 +258,10 @@ struct MacSettingsView: View {
title: MacL10n.string("mac.settings.asrModel", language: lang),
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
model: $viewModel.config.asrModel,
providerIdentity: viewModel.config.asrProviderId,
endpointIdentity: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
fetchModels: fetchMacASRModels,
makeFetchModelsRequest: makeMacASRModelsRequest,
language: lang
)
.id(viewModel.config.asrProviderId)
@@ -443,40 +447,66 @@ struct MacSettingsView: View {
viewModel.config.asrApiKey = fields.encodedAPIKey
}
private func validateMacLLM() async throws {
@MainActor
private func makeMacLLMValidateRequest() -> ProviderToolRequest<Void> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let model = viewModel.config.model
let thinkingEnabled = viewModel.config.llmThinkingEnabled
return ProviderToolRequest(providerIdentity: providerID) {
let client = LLMClientFactory.make(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
model: viewModel.config.model,
thinkingEnabled: viewModel.config.llmThinkingEnabled
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
model: model,
thinkingEnabled: thinkingEnabled
)
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
}
private func fetchMacLLMModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: viewModel.config.providerId,
baseURL: viewModel.config.baseURL,
apiKey: viewModel.config.apiKey,
currentModel: viewModel.config.model
)
}
private func validateMacASR() async throws {
@MainActor
private func makeMacLLMModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.providerId
let baseURL = viewModel.config.baseURL
let apiKey = viewModel.config.apiKey
let currentModel = viewModel.config.model
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listLLMModels(
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
@MainActor
private func makeMacASRValidateRequest() -> ProviderToolRequest<Void> {
let persisted = AppGroupStore(defaults: viewModel.defaults)
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
let providerID = viewModel.config.asrProviderId
return ProviderToolRequest(providerIdentity: providerID) {
try await CloudASRConnectionCheck.validate(store: store)
}
}
private func fetchMacASRModels() async throws -> [String] {
@MainActor
private func makeMacASRModelsRequest() -> ProviderToolRequest<[String]> {
let providerID = viewModel.config.asrProviderId
let baseURL = viewModel.config.asrBaseURL
let apiKey = viewModel.config.asrApiKey
let currentModel = viewModel.config.asrModel
return ProviderToolRequest(providerIdentity: providerID) {
try await ProviderModelService.listASRModels(
providerId: viewModel.config.asrProviderId,
baseURL: viewModel.config.asrBaseURL,
apiKey: viewModel.config.asrApiKey,
currentModel: viewModel.config.asrModel
providerId: providerID,
baseURL: baseURL,
apiKey: apiKey,
currentModel: currentModel
)
}
}
// MARK: - Bindings
@@ -87,7 +87,9 @@ public struct LiveConfigurationSnapshot {
}
}
/// Ephemeral `ConfigurationStore` backed by a user-edited snapshot.
/// Ephemeral `ConfigurationStore` backed by an immutable capture of the edited
/// values. API keys remain in this in-memory snapshot and are never persisted
/// by the store; `@unchecked Sendable` covers the referenced UserDefaults handle.
public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable {
private let snapshot: LiveConfigurationSnapshot
@@ -32,7 +32,11 @@ public struct EditTextPager: View {
public var body: some View {
GeometryReader { proxy in
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
// Two pages only prefer HStack so page `1` is laid out in the
// same update that sets `scrollPosition`, avoiding a stuck
// LazyHStack that leaves the indicator on edited while still
// showing the original text.
HStack(spacing: 0) {
textPage(title: originalTitle, text: originalText)
.frame(
width: proxy.size.width,
@@ -1,41 +0,0 @@
// PolishStyleIconBadge.swift
// OSGKeyboard · Shared
//
// Circular SF Symbol badge for polish-style cards. Fixed footprint keeps icons
// visually consistent across built-in and user-defined styles on iOS and macOS.
import SwiftUI
public struct PolishStyleIconBadge: View {
@Environment(\.themePalette) private var palette
public let systemImage: String
public var isSelected: Bool
private let circleSize: CGFloat = 40
private let iconSize: CGFloat = 18
public init(pack: PolishStylePack, isSelected: Bool = false) {
self.systemImage = PolishStylePackCatalog.systemImage(for: pack.id)
self.isSelected = isSelected
}
public init(systemImage: String, isSelected: Bool = false) {
self.systemImage = systemImage
self.isSelected = isSelected
}
public var body: some View {
ZStack {
Circle()
.fill(isSelected ? palette.accentMuted : palette.surfaceMuted)
.frame(width: circleSize, height: circleSize)
Image(systemName: systemImage)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(isSelected ? palette.accent : palette.textSecondary)
.symbolRenderingMode(.hierarchical)
}
.frame(width: circleSize, height: circleSize)
.accessibilityHidden(true)
}
}
@@ -25,6 +25,7 @@ public struct RecordButton: View {
public let level: Double
public let remainingSeconds: Int?
public let isEnabled: Bool
public let usesLiquidGlass: Bool
public let onToggle: () -> Void
public let onPressingChanged: (Bool) -> Void
/// When non-nil, a 0.45s hold starts explicit editing of the last insertion.
@@ -42,6 +43,7 @@ public struct RecordButton: View {
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
usesLiquidGlass: Bool = false,
onToggle: @escaping () -> Void,
onPressingChanged: @escaping (Bool) -> Void = { _ in },
onEditLongPressBegan: (() -> Void)? = nil
@@ -50,6 +52,7 @@ public struct RecordButton: View {
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.usesLiquidGlass = usesLiquidGlass
self.onToggle = onToggle
self.onPressingChanged = onPressingChanged
self.onEditLongPressBegan = onEditLongPressBegan
@@ -104,11 +107,22 @@ public struct RecordButton: View {
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
if usesLiquidGlass {
Circle()
.fill(.clear)
.glassEffect(
.regular
.tint(glassTint)
.interactive(),
in: .circle
)
} else {
Circle()
.fill(discGradient)
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.blendMode(.overlay)
}
Group {
switch phase {
@@ -171,6 +185,19 @@ public struct RecordButton: View {
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
}
private var glassTint: Color {
switch phase {
case .recording:
return recordingTint
case .error, .idleUnavailable:
return palette.warning.opacity(0.85)
case .preparing, .processing:
return palette.surfaceElevated
case .idleReady:
return palette.accent
}
}
private var isIdle: Bool {
switch phase {
case .idleReady, .idleUnavailable:
@@ -1,110 +0,0 @@
// TranslationChip.swift
// OSGKeyboard · Shared
//
// Translation target picker chip shared between keyboard extension and
// host-app preview surfaces.
import SwiftUI
public struct TranslationChip: View, Equatable {
public let palette: ThemePalette
public let targetLocaleId: String
public let onSelect: (String) -> Void
public init(
palette: ThemePalette,
targetLocaleId: String,
onSelect: @escaping (String) -> Void
) {
self.palette = palette
self.targetLocaleId = targetLocaleId
self.onSelect = onSelect
}
nonisolated public static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
public var body: some View {
Menu {
ForEach(TranslationLanguageCatalog.all) { language in
Button {
onSelect(language.id)
} label: {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
}
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled))
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(background(enabled: enabled), in: Capsule())
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return SharedL10n.string("keyboard.translation.offMenu")
}
return language.nativeName
}
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
if !enabled {
return SharedL10n.string("keyboard.translation.chip")
}
return "\(shortLabel(for: target))"
}
private func shortLabel(for target: TranslationLanguage) -> String {
switch target.id {
case "en": return "EN"
case "zh-Hans": return ""
case "zh-Hant": return ""
case "ja": return ""
case "ko": return ""
case "fr": return "FR"
case "de": return "DE"
case "es": return "ES"
case "ru": return "RU"
case "pt": return "PT"
default: return target.promptLanguageName
}
}
private func foreground(enabled: Bool) -> Color {
enabled ? palette.accent : palette.textPrimary
}
private func background(enabled: Bool) -> Color {
enabled ? palette.accent.opacity(0.15) : palette.surfaceElevated
}
private func stroke(enabled: Bool) -> Color {
enabled ? palette.accent.opacity(0.35) : palette.divider
}
}
+164
View File
@@ -0,0 +1,164 @@
// AIHintModels.swift
// OSGKeyboard · Shared
//
// Hint cards for the AI-mode idle carousel. Remote packs use `text`; the
// host compresses that into `displayText` before writing the ready pack.
import Foundation
public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
public let id: String
/// One-line carousel label (after host keyword pass, or local catalog).
public var displayText: String
/// Full user message sent to the AI question LLM on tap.
public var prompt: String
public var category: String
public var priority: Int
public var source: String
public var locale: String
public var conditions: [String]
public init(
id: String,
displayText: String,
prompt: String,
category: String,
priority: Int = 50,
source: String = "local",
locale: String = "zh",
conditions: [String] = []
) {
self.id = id
self.displayText = displayText
self.prompt = prompt
self.category = category
self.priority = priority
self.source = source
self.locale = locale
self.conditions = conditions
}
public var requiresClipboard30s: Bool {
conditions.contains("clipboard_30s") || category == "clipboard"
}
enum CodingKeys: String, CodingKey {
case id, displayText, text, prompt, category, priority, source, locale, conditions
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
prompt = try container.decode(String.self, forKey: .prompt)
category = try container.decodeIfPresent(String.self, forKey: .category) ?? "general"
priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 50
source = try container.decodeIfPresent(String.self, forKey: .source) ?? "remote"
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
!display.isEmpty {
displayText = display
} else {
displayText = try container.decodeIfPresent(String.self, forKey: .text) ?? ""
}
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(displayText, forKey: .displayText)
try container.encode(prompt, forKey: .prompt)
try container.encode(category, forKey: .category)
try container.encode(priority, forKey: .priority)
try container.encode(source, forKey: .source)
try container.encode(locale, forKey: .locale)
try container.encode(conditions, forKey: .conditions)
}
}
public struct AIHintPack: Codable, Equatable, Sendable {
public var locale: String
public var generatedAt: String?
public var expiresAt: String?
public var version: Int
public var cards: [AIHintCard]
/// Wall-clock when the host last successfully wrote this ready pack.
public var refreshedAt: Date?
public init(
locale: String,
generatedAt: String? = nil,
expiresAt: String? = nil,
version: Int = 1,
cards: [AIHintCard] = [],
refreshedAt: Date? = nil
) {
self.locale = locale
self.generatedAt = generatedAt
self.expiresAt = expiresAt
self.version = version
self.cards = cards
self.refreshedAt = refreshedAt
}
}
public struct AIHintManifest: Codable, Equatable, Sendable {
public var generatedAt: String?
public var expiresAt: String?
public var intervalHours: Int?
public var locales: [String]?
public var files: [String: String?]?
public init(
generatedAt: String? = nil,
expiresAt: String? = nil,
intervalHours: Int? = nil,
locales: [String]? = nil,
files: [String: String?]? = nil
) {
self.generatedAt = generatedAt
self.expiresAt = expiresAt
self.intervalHours = intervalHours
self.locales = locales
self.files = files
}
}
public enum AIHintFeedEndpoints {
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
/// Packs the app fetches and the keyboard can resolve.
public static let supportedLocales = ["zh", "en"]
public static func packURL(locale: String) -> URL {
baseURL.appendingPathComponent("hints-\(locale).json")
}
}
public enum AIHintLocaleResolver {
/// Only `zh-Hans` uses the Chinese pack; everything else uses English.
public static func packLocale(
preferredLanguages: [String] = Locale.preferredLanguages
) -> String {
let primary = (preferredLanguages.first ?? "").lowercased()
if primary == "zh-hans" || primary.hasPrefix("zh-hans-") || primary.hasPrefix("zh-hans_") {
return "zh"
}
return "en"
}
}
public enum AIHintAppGroupKeys {
public static let readyPackPrefix = "hints.ready."
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
public static func readyPackKey(locale: String) -> String {
readyPackPrefix + locale
}
/// Freshness is tracked per locale so a zh success cannot mask an en failure.
public static func lastSuccessKey(locale: String) -> String {
lastSuccessPrefix + locale
}
}
@@ -49,7 +49,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let polishStyleCatalog = "config.polishStyles.v1"
public static let activePolishStyleId = "config.activePolishStyleId"
public static let polishStylesMigrated = "config.polishStyles.migrated"
/// Keys used by the removed pre-v0.3 manual scenario implementation.
/// Legacy keys from the removed manual scenario implementation.
public static let legacyPolishScenarioId = "config.polishScenarioId"
public static let legacySystemPrompt = "config.systemPrompt"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
@@ -230,7 +230,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return load(fromAvailable: store)
}
/// Loads configuration from a known-available UserDefaults suite.
/// Loads and idempotently migrates a known-available suite; this is not a
/// pure read. Missing defaults distinguish upgrades (legacy cloud engine)
/// from fresh installs (local engine) before being persisted.
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
let storedProviderId = defaults.string(forKey: Keys.providerId)
var config = AppGroupConfiguration(
@@ -336,6 +338,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
// Legacy qwen cloud ASR bailian realtime (HTTP Flash path removed).
if config.asrProviderId == "qwen" {
do {
try Keychain.copyQwenASRKeyToBailian(
useICloudSync: config.settingsICloudSyncEnabled
)
} catch {
OSGLog.config.warning(
"qwen ASR credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
let bailian = LLMProvider.provider(id: "bailian")
config.asrProviderId = "bailian"
config.asrBaseURL = bailian.defaultBaseURL
@@ -508,7 +519,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
}
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
/// Resolves the provider-scoped Keychain item, then the legacy `current`
/// account, then plaintext defaults. Legacy sources are removed after the
/// selected local or synchronizable target is read back exactly.
static func resolveAPIKey(
defaults: UserDefaults?,
providerId: String,
@@ -518,20 +531,40 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return stored
}
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
try? Keychain.deleteLegacyAPIKey()
do {
try Keychain.migrateLegacyAPIKey(
to: providerId,
useICloudSync: preferICloudSync
)
} catch {
OSGLog.config.warning(
"legacy Keychain credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
return legacyKeychain
}
if let defaults,
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
do {
try Keychain.copyAPIKeyToSelectedStorage(
legacy,
providerId: providerId,
useICloudSync: preferICloudSync
)
defaults.removeObject(forKey: Keys.apiKeyLegacy)
} catch {
OSGLog.config.warning(
"legacy defaults credential migration deferred: \(String(describing: error), privacy: .public)"
)
}
return legacy
}
return ""
}
/// Resolves the ASR-scoped account first, then falls back to the matching
/// polish-provider account used before ASR credentials were split.
static func resolveASRAPIKey(
defaults: UserDefaults?,
providerId: String,
@@ -540,6 +573,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
return stored
}
if providerId == "bailian" {
try? Keychain.copyQwenASRKeyToBailian(useICloudSync: preferICloudSync)
if let migrated = Keychain.asrApiKey(
for: providerId,
preferICloudSync: preferICloudSync
), !migrated.isEmpty {
return migrated
}
// Keep a compatibility read while older signed installs may still
// hold the DashScope credential under qwen accounts.
if let legacyQwen = Keychain.asrApiKey(
for: "qwen",
preferICloudSync: preferICloudSync
), !legacyQwen.isEmpty {
return legacyQwen
}
}
// Pre-split installs: one shared key under `provider.<id>`.
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
}
@@ -60,7 +60,8 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
now >= expiresAt
}
/// Rebuilt extensions must prove the entire insertion is still at the caret.
/// Rebuilt extensions must match the complete inserted string at the caret
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
public func isFullyVerified(
contextBeforeInput: String?,
fieldFingerprint: String?
@@ -75,6 +76,9 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
}
}
/// App Group cache shared across extension instances. Loads evict references
/// after their TTL; callers must never save secure-field text because this is
/// cross-process persistence, not protected credential storage.
public enum EditableInputReferenceStore {
private static let key = "editLastInput.reference.v1"
@@ -0,0 +1,43 @@
// FlowAck.swift
// OSGKeyboard · Shared
import Foundation
/// Keyboard delivery acknowledgement. It carries the echoed result identity,
/// generation, and revision; matching identity/revision releases that terminal result.
public struct FlowAck: Codable, Equatable, Sendable {
public enum DeliveryOutcome: String, Codable, Sendable {
case replaced
case appended
case rejected
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let hostGeneration: String?
public let revision: Int64?
public let deliveryOutcome: DeliveryOutcome?
public let consumedAt: TimeInterval
public init(
protocolVersion: Int = 1,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
hostGeneration: String? = nil,
revision: Int64? = nil,
deliveryOutcome: DeliveryOutcome? = nil,
consumedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.hostGeneration = hostGeneration
self.revision = revision
self.deliveryOutcome = deliveryOutcome
self.consumedAt = consumedAt
}
}
@@ -0,0 +1,87 @@
// FlowCommand.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowCommand: Codable, Equatable, Sendable {
public enum Action: String, Codable, Sendable {
case startRecording
case stopRecording
case abort
/// Light warm-up: ASR locale/assets only no mic capture.
case prewarm
/// User has touched the mic; prime capture before tap/hold resolves.
case primeAudio
/// Touch ended without an utterance adopting the primed capture.
case cancelPrimeAudio
/// Remove one temporary AI conversation from host memory.
case endAIConversation
/// AI mode: submit a prefilled question and skip ASR.
case submitAIQuestion
}
/// Wire version that includes submitAIQuestion + aiQuestionText.
public static let currentProtocolVersion = 5
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let action: Action
public let localeId: String
public let createdAt: TimeInterval
public let fieldContext: FlowFieldContext?
/// Dictation (default) vs explicit edit mode. Absent on legacy v1 dictation.
public let utteranceMode: FlowUtteranceMode?
/// Verified source for explicit last-input editing.
public let editSourceText: String?
public let sourceHistoryEntryID: UUID?
public let sourceHistoryEntryRevision: Int64?
/// Host-memory conversation used only by `.aiQuestion`.
public let aiConversationID: UUID?
/// Prefilled question used only by `.submitAIQuestion`.
public let aiQuestionText: String?
/// Absolute wall-clock deadlines survive extension reconstruction.
public let startDeadlineAt: TimeInterval?
public let processingDeadlineAt: TimeInterval?
public init(
protocolVersion: Int = FlowCommand.currentProtocolVersion,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
action: Action,
localeId: String,
createdAt: TimeInterval = Date().timeIntervalSince1970,
fieldContext: FlowFieldContext? = nil,
utteranceMode: FlowUtteranceMode? = nil,
editSourceText: String? = nil,
sourceHistoryEntryID: UUID? = nil,
sourceHistoryEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil,
aiQuestionText: String? = nil,
startDeadlineAt: TimeInterval? = nil,
processingDeadlineAt: TimeInterval? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.action = action
self.localeId = localeId
self.createdAt = createdAt
self.fieldContext = fieldContext
self.utteranceMode = utteranceMode
self.editSourceText = editSourceText
self.sourceHistoryEntryID = sourceHistoryEntryID
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
self.aiConversationID = aiConversationID
self.aiQuestionText = aiQuestionText
self.startDeadlineAt = startDeadlineAt
self.processingDeadlineAt = processingDeadlineAt
}
public var resolvedUtteranceMode: FlowUtteranceMode {
utteranceMode ?? .dictation
}
}
@@ -0,0 +1,43 @@
// FlowFieldContext.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowFieldContext: Codable, Equatable, Sendable {
public let precedingText: String?
public let followingText: String?
public let keyboardType: String?
public let returnKeyType: String?
public let isSecureEntry: Bool
/// Distinguishes a known-empty field from unavailable document context.
public let isEmptyField: Bool
public let isContextAvailable: Bool
public init(
precedingText: String? = nil,
followingText: String? = nil,
keyboardType: String? = nil,
returnKeyType: String? = nil,
isSecureEntry: Bool = false,
isEmptyField: Bool = false,
isContextAvailable: Bool = false
) {
self.precedingText = isSecureEntry ? nil : precedingText
self.followingText = isSecureEntry ? nil : followingText
self.keyboardType = keyboardType
self.returnKeyType = returnKeyType
self.isSecureEntry = isSecureEntry
self.isEmptyField = isSecureEntry ? false : isEmptyField
self.isContextAvailable = isSecureEntry ? false : isContextAvailable
}
public var deliveryFingerprint: String? {
guard !isSecureEntry else { return nil }
return [
keyboardType ?? "",
returnKeyType ?? "",
precedingText.map { String($0.suffix(80)) } ?? "",
followingText.map { String($0.prefix(40)) } ?? "",
].joined(separator: "|")
}
}
@@ -0,0 +1,67 @@
// FlowReadySnapshot.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
public enum Reason: String, Codable, Sendable {
case ready
case noSession
case starting
case audioEngineNotLive
case waitingForAudioProof
case recording
case processing
case awaitingDelivery
case permissionMissing
case appGroupUnavailable
case hostLost
case error
}
public let protocolVersion: Int
public let sessionId: UUID?
public let ready: Bool
public let reason: Reason
public let heartbeatAt: TimeInterval
public let readyAt: TimeInterval?
public let audioProofAt: TimeInterval?
public let engineMode: String
public let localeId: String
public let busyUtteranceId: UUID?
public let sessionExpiresAt: TimeInterval?
/// Host process generation that wrote this snapshot. A snapshot whose
/// generation no longer matches `FlowSessionKeys.hostGeneration` was
/// written by a dead process and is void immediately no need to wait
/// out the heartbeat-zombie window. Optional for wire compatibility with
/// snapshots written before this field existed.
public let hostGeneration: String?
public init(
protocolVersion: Int = 1,
sessionId: UUID?,
ready: Bool,
reason: Reason,
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
readyAt: TimeInterval? = nil,
audioProofAt: TimeInterval? = nil,
engineMode: String,
localeId: String,
busyUtteranceId: UUID? = nil,
sessionExpiresAt: TimeInterval? = nil,
hostGeneration: String? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.ready = ready
self.reason = reason
self.heartbeatAt = heartbeatAt
self.readyAt = readyAt
self.audioProofAt = audioProofAt
self.engineMode = engineMode
self.localeId = localeId
self.busyUtteranceId = busyUtteranceId
self.sessionExpiresAt = sessionExpiresAt
self.hostGeneration = hostGeneration
}
}
@@ -0,0 +1,88 @@
// FlowResult.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowResult: Codable, Equatable, Sendable {
public enum Status: String, Codable, Sendable {
case partial
case rawReady
/// AI-mode LLM answer draft (not ASR). Non-terminal.
case streaming
case final
case error
case aborted
case timeout
}
public let protocolVersion: Int
public let sessionId: UUID
public let utteranceId: UUID
public let commandSeq: Int64
public let status: Status
public let text: String?
public let warning: String?
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
/// Raw ASR survives polish/network failure and host process churn.
public let rawText: String?
public let hostGeneration: String?
/// Monotonically increases within one session/utterance; readers may discard
/// an equal or lower non-nil revision as a stale or duplicate delivery.
public let revision: Int64?
public let fieldFingerprint: String?
public let createdAt: TimeInterval
/// Echo of the command mode so the extension can skip raw fallback.
public let utteranceMode: FlowUtteranceMode?
/// History row created by normal dictation, or edited by edit mode.
public let historyEntryID: UUID?
public let historyEntryRevision: Int64?
/// Echoed for AI result validation; absent for dictation and edit.
public let aiConversationID: UUID?
public init(
protocolVersion: Int = FlowCommand.currentProtocolVersion,
sessionId: UUID,
utteranceId: UUID,
commandSeq: Int64,
status: Status,
text: String? = nil,
warning: String? = nil,
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
rawText: String? = nil,
hostGeneration: String? = nil,
revision: Int64? = nil,
fieldFingerprint: String? = nil,
createdAt: TimeInterval = Date().timeIntervalSince1970,
utteranceMode: FlowUtteranceMode? = nil,
historyEntryID: UUID? = nil,
historyEntryRevision: Int64? = nil,
aiConversationID: UUID? = nil
) {
self.protocolVersion = protocolVersion
self.sessionId = sessionId
self.utteranceId = utteranceId
self.commandSeq = commandSeq
self.status = status
self.text = text
self.warning = warning
self.errorKind = errorKind
self.rawText = rawText
self.hostGeneration = hostGeneration
self.revision = revision
self.fieldFingerprint = fieldFingerprint
self.createdAt = createdAt
self.utteranceMode = utteranceMode
self.historyEntryID = historyEntryID
self.historyEntryRevision = historyEntryRevision
self.aiConversationID = aiConversationID
}
public var resolvedUtteranceMode: FlowUtteranceMode {
utteranceMode ?? .dictation
}
/// Instruction deliveries must never insert raw ASR into the field.
public var allowsRawFallback: Bool {
resolvedUtteranceMode == .dictation
}
}
@@ -0,0 +1,33 @@
// FlowStartTransaction.swift
// OSGKeyboard · Shared
import Foundation
public struct FlowStartTransaction: Codable, Equatable, Sendable {
public enum Phase: String, Codable, Sendable {
case issued
case starting
case recording
case terminal
}
public let sessionID: UUID
public let utteranceID: UUID
public let deadlineAt: TimeInterval
public let phase: Phase
public let updatedAt: TimeInterval
public init(
sessionID: UUID,
utteranceID: UUID,
deadlineAt: TimeInterval,
phase: Phase,
updatedAt: TimeInterval = Date().timeIntervalSince1970
) {
self.sessionID = sessionID
self.utteranceID = utteranceID
self.deadlineAt = deadlineAt
self.phase = phase
self.updatedAt = updatedAt
}
}

Some files were not shown because too many files have changed in this diff Show More