[P0-③] API Key data flow fix

- AppGroup.defaults: in DEBUG, missing App Group is a hard fatalError
  with a precise remediation message (was a soft print + .standard
  fallback, which desynced the keyboard extension from the main App).
  Release keeps the fallback + NSLog so end-users still get a usable app.
- KeyboardViewController.loadPersistedLocale now prints a masked DEBUG
  view of the live App Group config (provider, baseURL, masked key,
  model, mode, locale) so the extension's view is visible in the
  device console.
- KeyboardViewController.handleFinalTranscript now routes by typed error:
    noAPIKey  → red error '未配置 API Key · 请在主 App 设置中填写'
    http 401  → red error 'API Key 无效 (401) · 请检查主 App 设置'
    http 429  → red error 'API 限流 (429) · 请稍后再试'
    other     → insert raw transcript + generic error badge
- APISettingsCard gains a 'Test connection' button that runs a single
  client.polish('ping') round-trip and surfaces the typed result inline.
- PolishingService.timeout raised 12s → 15s to match LLMClient.request
  timeout (was racing and discarding successful responses in 12–15s).
- Tests: 4 new cases (HTTP 429, transport timeout, App Group cross-process,
  AppGroupStore→LLMClient noAPIKey). All 8 tests pass on iPhone 16e sim.

xcodebuild iOS Simulator: SUCCEEDED
xcodebuild test: 8/8 passed
This commit is contained in:
Zhongshu
2026-06-18 10:50:02 +08:00
parent e93bab50b4
commit 2e2d8e33b3
5 changed files with 311 additions and 13 deletions
+50 -1
View File
@@ -188,6 +188,29 @@ public final class KeyboardViewController: UIInputViewController {
let id = store.localeId
state.localeId = id
state.mode = State.InputMode(rawValue: store.modeId) ?? .polish
#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>"
}
print("""
🔍 [KeyboardViewController.loadPersistedLocale]
providerId = \(store.providerId)
baseURL = \(store.baseURL)
apiKey = \(masked)
model = \(store.model)
modeId = \(store.modeId)
localeId = \(store.localeId)
""")
#endif
}
// MARK: - Press handlers
@@ -306,8 +329,34 @@ public final class KeyboardViewController: UIInputViewController {
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
self.state.phase = .idle
} catch LLMError.noAPIKey {
// Don't silently insert the raw transcript the user
// thinks they're getting polished text when really no
// key is configured. Show a precise, actionable error.
self.state.phase = .error("未配置 API Key · 请在主 App 设置中填写")
self.scheduleAutoClearError()
} catch let error as LLMError {
switch error {
case .http(401):
self.state.phase = .error("API Key 无效 (401) · 请检查主 App 设置")
self.scheduleAutoClearError()
case .http(429), .rateLimited:
self.state.phase = .error("API 限流 (429) · 请稍后再试")
self.scheduleAutoClearError()
default:
// Other LLMError variants (transport / decoding /
// invalidURL / cancelled) fall back to raw transcript
// + generic error badge, same as the catch-all below.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
let msg = error.errorDescription ?? "Polishing failed — inserted raw."
self.state.phase = .error(msg)
self.scheduleAutoClearError()
}
} catch {
// Fall back to raw transcript on any failure.
// Network / timeout / decoding fall back to the raw
// transcript so the user still gets their text, with a
// visible error badge.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
let msg = (error as? LocalizedError)?.errorDescription