feat(keyboard): harden polish/clipboard guards and ship 1.6.6 (build 59)

Keep marketing version at 1.6.6 and bump build to 59. Strengthen never-answer
polish safeguards, clipboard reply-intent continuity, voice undo UX, device
UITests harness, eval fixtures, and What's New assets.
This commit is contained in:
Rocky
2026-08-09 12:45:33 +08:00
parent bfe2cd2001
commit 5d7fcdf24e
41 changed files with 2876 additions and 464 deletions
@@ -0,0 +1,277 @@
// ClipboardCommandUITests.swift
// OSGKeyboard · UI Tests (device reproduction harness)
//
// Drives the real long-press clipboard-command round on a
// physical device, which is the only path Apple exposes for injecting touches
// outside the Simulator. Pair with an Instruments capture so the unified log of
// both the host app and the keyboard extension can be replayed per round:
//
// xcrun xctrace record --device <UDID> --template Logging --all-processes \
// --output round.trace
//
// Knobs (environment, read at runtime):
// OSG_ROUNDS number of long-press rounds (default 5)
// OSG_ALLOW_DELAY seconds to leave the paste alert up (default 1.0)
// OSG_SETTLE seconds to observe after allowing (default 12)
// OSG_PICK_KEYBOARD set to 1 to long-press globe and pick OSGKeyboard
import XCTest
import UIKit
import os
@MainActor
final class ClipboardCommandUITests: XCTestCase {
private let log = Logger(subsystem: "com.osgkeyboard.uitest", category: "round")
private lazy var app = XCUIApplication()
private lazy var springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
private var rounds: Int { Int(env("OSG_ROUNDS") ?? "") ?? 5 }
private var allowDelay: TimeInterval { Double(env("OSG_ALLOW_DELAY") ?? "") ?? 1.0 }
private var settle: TimeInterval { Double(env("OSG_SETTLE") ?? "") ?? 12 }
private func env(_ key: String) -> String? {
ProcessInfo.processInfo.environment[key]
}
override func setUpWithError() throws {
continueAfterFailure = true
}
// MARK: - Test
func testClipboardLongPressRounds() throws {
app.launch()
XCTAssertTrue(app.wait(for: .runningForeground, timeout: 20), "host app did not foreground")
mark("session.begin rounds=\(rounds) allowDelay=\(allowDelay)")
attach("launch")
mark("launch.tree textViews=\(app.textViews.count) textFields=\(app.textFields.count) "
+ "keyboards=\(app.keyboards.count) buttons=\(app.buttons.count) "
+ "tabs=\(app.tabBars.buttons.count)")
focusPreviewField()
attach("after-focus")
try selectOSGKeyboardIfRequested()
for round in 1...rounds {
runRound(round)
}
mark("session.end")
}
// MARK: - Round
private func runRound(_ round: Int) {
let text = "\(round)轮剪贴板命令测试文本,请把它改写得更正式一些。"
// Recording chrome only listens for taps (long-press is idle-only).
// Clear any leftoverbefore we seed the next round.
focusPreviewField()
stopRecordingIfNeeded(tag: "round.\(round).preclear")
// UITest Runner on device cannot write UIPasteboard.general (PBErrorDomain
// Code=10/11). Seed via the host text field + system edit menu instead.
let seeded = seedClipboardViaHostField(text)
mark("round.\(round).pasteboardSeeded=\(seeded)")
focusPreviewField()
attach("r\(round)-before-press")
mark("round.\(round).longPress.begin")
mic.press(forDuration: 1.2)
let allowed = handlePasteAlert(round: round)
mark("round.\(round).pasteAlert allowed=\(allowed)")
let deadline = Date().addingTimeInterval(settle)
var frame = 0
while Date() < deadline {
Thread.sleep(forTimeInterval: 2.0)
frame += 1
attach("r\(round)-settle-\(frame)")
}
// Tap-to-stop after observation exercises the exit path users use.
stopRecordingIfNeeded(tag: "round.\(round).poststop")
mark("round.\(round).end")
dismissKeyboard()
}
/// While phase is recording/processing the mic only has `onTapGesture`.
/// A XCUITest long-press will not stop an active clipboard utterance.
private func stopRecordingIfNeeded(tag: String) {
attach("\(tag)-before-tap")
mic.tap()
Thread.sleep(forTimeInterval: 1.2)
attach("\(tag)-after-tap")
mark("\(tag) mic.tap")
}
/// The system paste-consent alert is owned by SpringBoard, not the app.
private func handlePasteAlert(round: Int) -> Bool {
let allow = springboard.buttons.matching(
NSPredicate(format: "label IN {'允许粘贴', 'Allow Paste'}")
).firstMatch
// Also try the host app hierarchy some iOS builds host the sheet there.
let allowInApp = app.buttons.matching(
NSPredicate(format: "label IN {'允许粘贴', 'Allow Paste'}")
).firstMatch
let button: XCUIElement
if allow.waitForExistence(timeout: 4) {
button = allow
} else if allowInApp.waitForExistence(timeout: 4) {
button = allowInApp
} else {
return false
}
attach("r\(round)-alert")
// Hold the alert open so wall-clock deadlines armed before the press
// can be pushed past their expiry the failure mode under test.
Thread.sleep(forTimeInterval: allowDelay)
mark("round.\(round).allowTap")
button.tap()
return true
}
// MARK: - Clipboard seeding
/// Put `text` on the device pasteboard by typing into the host preview field
/// and using Select All Copy. Returns whether Copy appeared and was tapped.
@discardableResult
private func seedClipboardViaHostField(_ text: String) -> Bool {
let field = previewField()
guard field.waitForExistence(timeout: 8) else {
mark("seed.fail noPreviewField")
return false
}
field.tap()
Thread.sleep(forTimeInterval: 0.4)
// Clear whatever is already there so typeText does not append forever.
clearPreviewField(field)
field.typeText(text)
Thread.sleep(forTimeInterval: 0.4)
attach("seed-typed")
// Long-press to summon the edit menu.
field.press(forDuration: 1.0)
Thread.sleep(forTimeInterval: 0.5)
if !tapMenuItem(labels: ["全选", "Select All"]) {
// Double-tap often selects a word; try again for Select All.
field.doubleTap()
Thread.sleep(forTimeInterval: 0.4)
_ = tapMenuItem(labels: ["全选", "Select All"])
}
Thread.sleep(forTimeInterval: 0.3)
let copied = tapMenuItem(labels: ["拷贝", "Copy"])
mark("seed.copy tapped=\(copied)")
attach("seed-after-copy")
// Tap elsewhere to dismiss the menu, then clear the field so the next
// long-press exercises clipboard content rather than field text.
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.20)).tap()
Thread.sleep(forTimeInterval: 0.3)
field.tap()
clearPreviewField(field)
return copied
}
private func clearPreviewField(_ field: XCUIElement) {
// Select-all + delete via menu when possible; fall back to delete key events.
let current = field.value as? String ?? ""
guard !current.isEmpty, current != "点这里试试键盘" else { return }
field.press(forDuration: 1.0)
if tapMenuItem(labels: ["全选", "Select All"]) {
Thread.sleep(forTimeInterval: 0.2)
field.typeText(XCUIKeyboardKey.delete.rawValue)
return
}
// Fallback: spam delete for the current length (capped).
let n = min(current.count, 80)
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: n)
field.typeText(deletes)
}
@discardableResult
private func tapMenuItem(labels: [String]) -> Bool {
for label in labels {
let item = app.menuItems[label]
if item.waitForExistence(timeout: 1.5) {
item.tap()
return true
}
let springItem = springboard.menuItems[label]
if springItem.waitForExistence(timeout: 0.5) {
springItem.tap()
return true
}
}
return false
}
// MARK: - Keyboard plumbing
// A custom keyboard extension is not published as `app.keyboards` on a
// physical device, so every key is addressed by normalized screen offset.
// Measured against the OSG voice surface on iPhone 15 Pro Max.
private var mic: XCUICoordinate {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.50, dy: 0.78))
}
private var globe: XCUICoordinate {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.098, dy: 0.958))
}
private func previewField() -> XCUIElement {
if app.textFields.firstMatch.exists { return app.textFields.firstMatch }
return app.textViews.firstMatch
}
private func focusPreviewField() {
let field = previewField()
if field.waitForExistence(timeout: 10) {
field.tap()
} else {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.60)).tap()
}
Thread.sleep(forTimeInterval: 1.5)
}
private func dismissKeyboard() {
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)).tap()
Thread.sleep(forTimeInterval: 1.0)
}
/// Only needed when the device is not already left on the OSG keyboard.
private func selectOSGKeyboardIfRequested() throws {
guard env("OSG_PICK_KEYBOARD") == "1" else { return }
globe.press(forDuration: 1.3)
let entry = app.buttons["OSGKeyboard"].exists
? app.buttons["OSGKeyboard"]
: springboard.buttons["OSGKeyboard"]
if entry.waitForExistence(timeout: 6) {
entry.tap()
}
Thread.sleep(forTimeInterval: 2.0)
attach("keyboard-selected")
mark("keyboard.picked")
}
// MARK: - Evidence
private func mark(_ message: String) {
log.info("[uitest] \(message, privacy: .public)")
print("[uitest] \(message)")
}
private func attach(_ name: String) {
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
shot.name = name
shot.lifetime = .keepAlways
add(shot)
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>