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:
@@ -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
@@ -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
|
||||
|
||||
@@ -24,7 +24,13 @@ included:
|
||||
- OSGKeyboard
|
||||
- OSGKeyboardExt
|
||||
- OSGKeyboardShared
|
||||
- OSGKeyboardHostSupport
|
||||
- OSGKeyboardMac
|
||||
- OSGKeyboardTests
|
||||
- OSGKeyboardExtTests
|
||||
- OSGKeyboardMacTests
|
||||
- OSGKeyboardUITests
|
||||
- Tests
|
||||
|
||||
excluded:
|
||||
- build
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 三个 job(lint / 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" 起支持 iPad,App 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/polish)3) `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 provider:OpenAI / 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 app;host 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 17(CI `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×1024,App 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
@@ -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 user’s 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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 Apple’s 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 provider’s 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 OSGKeyboard’s 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 device’s 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 app’s 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 device’s 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,10 +14,6 @@ struct MainTabContent: View {
|
||||
switch tab {
|
||||
case .keyboard:
|
||||
HomeView()
|
||||
case .history:
|
||||
HistoryView()
|
||||
case .dictionary:
|
||||
PersonalDictionaryView()
|
||||
case .styles:
|
||||
PolishStylesView()
|
||||
case .settings:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 device’s 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.";
|
||||
|
||||
@@ -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.0(volc.seedasr.sauc.duration)。";
|
||||
"settings.asr.volcengine.note.apiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.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" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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 on「编辑后」instead 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)
|
||||
}
|
||||
|
||||
@@ -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 toolbar’s 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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user