diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 9dabd3b..e348c83 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -111,6 +111,7 @@ final class FlowSessionManager: ObservableObject { } FlowSessionBridge.writeHeartbeat() + FlowSessionDarwin.postSessionChanged() isActive = true if let expires = FlowSessionBridge.sessionExpiresAt() { sessionExpiresAt = Date(timeIntervalSince1970: expires) @@ -151,6 +152,7 @@ final class FlowSessionManager: ObservableObject { capture.stop() FlowSessionBridge.markSessionInactive() + FlowSessionDarwin.postSessionChanged() isActive = false sessionExpiresAt = nil sessionWarning = nil @@ -186,6 +188,7 @@ final class FlowSessionManager: ObservableObject { } FlowSessionBridge.markSessionActive(duration: duration) + FlowSessionDarwin.postSessionChanged() isActive = true sessionExpiresAt = Date().addingTimeInterval(duration) diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 01a187c..e0d90bd 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -62,6 +62,9 @@ public final class KeyboardViewController: UIInputViewController { private var flowWatchdogTask: Task? private var utteranceTimerTask: Task? private var utteranceStartedAt: TimeInterval = 0 + private var wasFlowSessionActive = false + private var flowSessionMonitorTask: Task? + private var flowSessionDarwinObserver: FlowSessionDarwinObserver? // MARK: - Lifecycle @@ -76,17 +79,22 @@ public final class KeyboardViewController: UIInputViewController { loadPersistedConfig() consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() + installFlowSessionDarwinObserver() + refreshFlowSessionState() } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) cancelPipeline() + stopFlowSessionMonitor() } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() + refreshFlowSessionState() + startFlowSessionMonitor() } public override func didReceiveMemoryWarning() { @@ -142,10 +150,57 @@ public final class KeyboardViewController: UIInputViewController { case .loaded: break case .unavailable: - state.phase = .error(.appGroupUnavailable, message: "App Group 未配置") + state.phase = .error( + .appGroupUnavailable, + message: ExtL10n.string("keyboard.error.appGroupUnavailable") + ) } } + // MARK: - Flow session monitor + + private func installFlowSessionDarwinObserver() { + flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in + self?.refreshFlowSessionState() + } + } + + private func startFlowSessionMonitor() { + flowSessionMonitorTask?.cancel() + flowSessionMonitorTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + self?.refreshFlowSessionState() + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + private func stopFlowSessionMonitor() { + flowSessionMonitorTask?.cancel() + flowSessionMonitorTask = nil + } + + private func refreshFlowSessionState() { + let active = FlowSessionBridge.isSessionActive() + state.flowSessionActive = active + + if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart { + switch state.phase { + case .recording, .processing: + break + default: + showFlowSessionExpiredHint() + } + } + wasFlowSessionActive = active + } + + private func showFlowSessionExpiredHint() { + let message = ExtL10n.string("keyboard.flow.sessionExpired") + state.phase = .error(.unknown(message), message: message) + scheduleAutoClearError() + } + // MARK: - Press handlers private func toggleRecording() { @@ -168,13 +223,13 @@ public final class KeyboardViewController: UIInputViewController { } guard state.mode != .off else { return } guard hasFullAccess else { - let msg = "请在系统设置中为 OSGKeyboard 开启“允许完全访问”,否则无法使用语音输入" + let msg = ExtL10n.string("keyboard.error.fullAccessRequired") state.phase = .error(.unknown(msg), message: msg) scheduleAutoClearError() return } guard AppGroup.isAvailable else { - let msg = "App Group 未配置,键盘无法与主 App 通信。请重新安装并检查签名配置。" + let msg = ExtL10n.string("keyboard.error.appGroupCommunication") state.phase = .error(.appGroupUnavailable, message: msg) scheduleAutoClearError() return @@ -198,7 +253,7 @@ public final class KeyboardViewController: UIInputViewController { stopUtteranceCountdown() FlowSessionBridge.setRecordingState(.stopped) state.phase = .processing - state.lastTranscript = "识别中..." + state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") startFlowResultWatchdog() } @@ -245,7 +300,7 @@ public final class KeyboardViewController: UIInputViewController { isPendingFlowStart = true isFlowRecording = false flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout - state.lastTranscript = "正在启动语音会话..." + state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession") state.phase = .processing openHostApp(path: "startflow") startFlowStartWatchdog() @@ -312,7 +367,7 @@ public final class KeyboardViewController: UIInputViewController { let now = Date().timeIntervalSince1970 if now - startedAt > FlowWatchdog.resultTimeout { self.stopFlowWatchdog() - let msg = "等待识别结果超时,请重试" + let msg = ExtL10n.string("keyboard.flow.resultTimeout") self.state.phase = .error(.unknown(msg), message: msg) self.scheduleAutoClearError() return @@ -396,13 +451,13 @@ public final class KeyboardViewController: UIInputViewController { // 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(.llm(error), message: "未配置 API Key · 请在主 App 设置中填写") + self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.noApiKey")) self.scheduleAutoClearError() case .http(401): - self.state.phase = .error(.llm(error), message: "API Key 无效 (401) · 请检查主 App 设置") + self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.unauthorized")) self.scheduleAutoClearError() case .http(429), .rateLimited: - self.state.phase = .error(.llm(error), message: "API 限流 (429) · 请稍后再试") + self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.rateLimited")) self.scheduleAutoClearError() case .cancelled: // User-initiated cancellation (e.g. mode switch mid- @@ -464,7 +519,7 @@ public final class KeyboardViewController: UIInputViewController { private func openHostApp(path: String = "settings") { guard hasFullAccess else { - let msg = "未开启“允许完全访问”,请先在键盘设置中打开" + let msg = ExtL10n.string("keyboard.error.fullAccessForJump") state.phase = .error(.unknown(msg), message: msg) scheduleAutoClearError() return @@ -485,7 +540,7 @@ public final class KeyboardViewController: UIInputViewController { // Flow start: auto-jump often fails in WeChat/Safari — keep polling // so a manually opened host app can still satisfy the session check. if path == "startflow", isPendingFlowStart { - state.lastTranscript = "无法自动跳转,请从主屏幕打开 OSGKeyboard,然后返回继续" + state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost") return } @@ -508,18 +563,18 @@ public final class KeyboardViewController: UIInputViewController { switch progress.status { case .requested: state.lastTranscript = state.isLocalEngine - ? "正在打开 OSGKeyboard(本地转写)..." - : "正在打开 OSGKeyboard..." + ? ExtL10n.string("keyboard.dictation.openingLocal") + : ExtL10n.string("keyboard.dictation.opening") case .recording: state.lastTranscript = state.isLocalEngine - ? "正在本地录音,请完成后返回当前输入页" - : "正在录音,请完成后返回当前输入页" + ? ExtL10n.string("keyboard.dictation.recordingLocal") + : ExtL10n.string("keyboard.dictation.recording") case .transcribing: state.lastTranscript = state.isLocalEngine - ? "本地识别中,请稍候并返回输入页" - : "识别中,请稍候并返回输入页" + ? ExtL10n.string("keyboard.dictation.transcribingLocal") + : ExtL10n.string("keyboard.dictation.transcribing") case .error: - let msg = progress.message ?? "录音失败,请重试" + let msg = progress.message ?? ExtL10n.string("keyboard.dictation.failed") debug("host returned error: \(msg)") awaitingDictationResult = false stopDictationWatchdog() @@ -538,7 +593,7 @@ public final class KeyboardViewController: UIInputViewController { let now = Date().timeIntervalSince1970 let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt if now - lastProgressAt > DictationWatchdog.timeout { - let timeoutMessage = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试" + let timeoutMessage = ExtL10n.string("keyboard.dictation.resultTimeout") debug("dictation timeout after \(Int(now - lastProgressAt))s") awaitingDictationResult = false stopDictationWatchdog() @@ -551,15 +606,15 @@ public final class KeyboardViewController: UIInputViewController { private func showManualSettingsHint(path: String = "settings") { let msg: String if !hasFullAccess { - msg = "请先开启 OSGKeyboard 的“允许完全访问”,否则键盘无法跳转到 App" + msg = ExtL10n.string("keyboard.error.fullAccessForJump") } else if path == "settings" { - msg = "系统拒绝了键盘跳转。请手动打开 OSGKeyboard App 进入设置页" + msg = ExtL10n.string("keyboard.error.manualOpenSettings") } else if path == "startflow" { - msg = "语音会话未启动。请从主屏幕打开 OSGKeyboard App,返回后再按麦克风" + msg = ExtL10n.string("keyboard.error.manualOpenForFlow") } else if state.isLocalEngine { - msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 完成本地转写,再返回输入页" + msg = ExtL10n.string("keyboard.error.manualOpenDictateLocal") } else { - msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 录音,再返回输入页" + msg = ExtL10n.string("keyboard.error.manualOpenDictate") } state.phase = .error(.unknown(msg), message: msg) scheduleAutoClearError() diff --git a/OSGKeyboardExt/Utilities/ExtL10n.swift b/OSGKeyboardExt/Utilities/ExtL10n.swift new file mode 100644 index 0000000..fca204d --- /dev/null +++ b/OSGKeyboardExt/Utilities/ExtL10n.swift @@ -0,0 +1,17 @@ +// ExtL10n.swift +// OSGKeyboard · Keyboard Extension +// +// Loads strings from the extension bundle. Replaces the old KeyboardL10n +// hard-coded fallback map — keys live in Localizable.strings. + +import Foundation + +enum ExtL10n { + static func string(_ key: String) -> String { + NSLocalizedString(key, bundle: .main, comment: "") + } + + static func format(_ key: String, _ args: CVarArg...) -> String { + String(format: string(key), locale: Locale.current, arguments: args) + } +} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 59eb721..142adce 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -86,7 +86,7 @@ public struct KeyboardRootView: View { .overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) - .accessibilityLabel(Text(KeyboardL10n.openSettingsA11y)) + .accessibilityLabel(Text("keyboard.openSettingsA11y")) } .padding(.horizontal, Spacing.md) } @@ -99,6 +99,7 @@ public struct KeyboardRootView: View { TranscriptLine( phase: state.phase, transcript: state.lastTranscript, + flowSessionActive: state.flowSessionActive, openSettings: state.openSettings ) .frame(height: 22) @@ -128,7 +129,7 @@ public struct KeyboardRootView: View { state.deleteBackward() } Button(action: state.insertSpace) { - Text(KeyboardL10n.space) + Text("keyboard.space") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) .frame(maxWidth: .infinity, minHeight: 42) @@ -139,7 +140,7 @@ public struct KeyboardRootView: View { ) } .buttonStyle(.plain) - .accessibilityLabel(Text(KeyboardL10n.space)) + .accessibilityLabel(Text("keyboard.space")) ToolbarIconButton(systemName: "return", label: "newline") { state.insertNewline() } @@ -194,19 +195,26 @@ private struct TranscriptLine: View { let phase: KeyboardViewController.State.Phase let transcript: String + let flowSessionActive: Bool let openSettings: () -> Void var body: some View { ZStack { switch phase { case .idle: - Text(KeyboardL10n.placeholderIdle) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) + if flowSessionActive { + Text("keyboard.placeholder.idle") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + } else { + Text("keyboard.flow.sessionInactive") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + } case .requestingPermissions: HStack(spacing: 6) { ProgressView().controlSize(.mini).tint(palette.textSecondary) - Text(KeyboardL10n.placeholderPreparing) + Text("keyboard.placeholder.preparing") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } @@ -220,7 +228,7 @@ private struct TranscriptLine: View { case .processing: HStack(spacing: 6) { ProgressView().controlSize(.mini).tint(palette.accent) - Text(transcript.isEmpty ? KeyboardL10n.placeholderProcessing : transcript) + Text(transcript.isEmpty ? String(localized: "keyboard.placeholder.processing") : transcript) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) .lineLimit(1) @@ -247,17 +255,17 @@ private struct TranscriptLine: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityHint(Text(KeyboardL10n.deniedHint)) + .accessibilityHint(Text("keyboard.deniedHint")) } } .frame(maxWidth: .infinity) .padding(.horizontal, Spacing.md) } - private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String { + private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> LocalizedStringKey { switch reason { - case .mic: return KeyboardL10n.micDenied - case .speech: return KeyboardL10n.speechDenied + case .mic: return "keyboard.denied.mic" + case .speech: return "keyboard.denied.speech" } } } @@ -309,21 +317,21 @@ private struct StatusBadge: View { EmptyView() case .recording: if onDeviceSupported { - dot(color: palette.recordRed, label: "REC") + dot(color: palette.recordRed, labelKey: "keyboard.status.rec") } else { - dot(color: palette.warning, label: "REC ⚠️", showWarning: true) + dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true) } case .processing: - dot(color: palette.accent, label: "···") + dot(color: palette.accent, labelKey: "keyboard.status.processing") case .error: - dot(color: palette.warning, label: "!") + dot(color: palette.warning, labelKey: "keyboard.status.error") case .denied: - dot(color: palette.warning, label: "!") + dot(color: palette.warning, labelKey: "keyboard.status.error") } } } - private func dot(color: Color, label: String, showWarning: Bool = false) -> some View { + private func dot(color: Color, labelKey: LocalizedStringKey, showWarning: Bool = false) -> some View { HStack(spacing: 4) { Circle() .fill(color) @@ -333,7 +341,7 @@ private struct StatusBadge: View { .font(.system(size: 9, weight: .bold)) .foregroundStyle(palette.warning) } - Text(label) + Text(labelKey) .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) } @@ -352,7 +360,7 @@ private struct LocalEngineChip: View { var body: some View { HStack(spacing: 4) { Image(systemName: "iphone.badge.checkmark") - Text(KeyboardL10n.localBadge) + Text("keyboard.placeholder.localBadge") } .font(TypeStyle.caption2) .foregroundStyle(palette.accent) @@ -363,34 +371,6 @@ private struct LocalEngineChip: View { } } -// MARK: - Extension text fallback -// -// Custom keyboard extensions can end up without the expected localized -// resource table when signing/project generation drifts. Keep a tiny -// in-code fallback map so UI never shows raw key names like -// "common.space" in production. -private enum KeyboardL10n { - private static var isChinese: Bool { - Locale.preferredLanguages.first?.hasPrefix("zh") == true - } - - static var space: String { isChinese ? "空格" : "Space" } - static var placeholderIdle: String { isChinese ? "点按说话" : "Tap to talk" } - static var placeholderPreparing: String { isChinese ? "准备中…" : "Preparing" } - static var placeholderProcessing: String { isChinese ? "处理中…" : "Processing" } - static var localBadge: String { isChinese ? "本地" : "On-device" } - static var micDenied: String { isChinese ? "麦克风被拒绝" : "Mic denied" } - static var speechDenied: String { isChinese ? "语音识别被拒绝" : "Speech denied" } - static var deniedHint: String { - isChinese - ? "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。" - : "Open OSGKeyboard settings to grant microphone / speech access." - } - static var openSettingsA11y: String { - isChinese ? "打开 OSGKeyboard 设置" : "Open OSGKeyboard settings" - } -} - // MARK: - Mode chip private struct ModeChip: View { @@ -429,12 +409,8 @@ private struct ModeChip: View { .menuStyle(.button) } - private func label(for m: KeyboardViewController.State.InputMode) -> String { - switch m { - case .off: return "Off" - case .transcribe: return "转写" - case .polish: return "润色" - } + private func label(for m: KeyboardViewController.State.InputMode) -> LocalizedStringKey { + LocalizedStringKey(m.labelKey) } private func icon(for m: KeyboardViewController.State.InputMode) -> String { @@ -454,13 +430,13 @@ private struct LocaleChip: View { let localeId: String let onChange: (String) -> Void - private let options: [(id: String, label: String)] = [ - ("auto", "Auto"), - ("zh-Hans", "简体"), - ("zh-Hant", "繁體"), - ("en-US", "EN"), - ("ja-JP", "日"), - ("ko-KR", "한") + 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 { @@ -470,9 +446,9 @@ private struct LocaleChip: View { onChange(o.id) } label: { if o.id == localeId { - Label(o.label, systemImage: "checkmark") + Label(LocalizedStringKey(o.labelKey), systemImage: "checkmark") } else { - Text(o.label) + Text(LocalizedStringKey(o.labelKey)) } } } @@ -493,7 +469,10 @@ private struct LocaleChip: View { .menuStyle(.button) } - private var currentLabel: String { - options.first(where: { $0.id == localeId })?.label ?? "Auto" + private var currentLabel: LocalizedStringKey { + if let key = options.first(where: { $0.id == localeId })?.labelKey { + return LocalizedStringKey(key) + } + return "locale.chip.auto" } } diff --git a/OSGKeyboardExt/en.lproj/Localizable.strings b/OSGKeyboardExt/en.lproj/Localizable.strings index cbce72c..07176e9 100644 --- a/OSGKeyboardExt/en.lproj/Localizable.strings +++ b/OSGKeyboardExt/en.lproj/Localizable.strings @@ -126,6 +126,51 @@ "keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."; "keyboard.tapToTalkA11y" = "Tap to talk"; +/* Flow session (keyboard) */ +"keyboard.flow.sessionInactive" = "Open OSGKeyboard to start voice session"; +"keyboard.flow.sessionExpired" = "Voice session ended. Open OSGKeyboard to restart."; +"keyboard.flow.startingSession" = "Starting voice session…"; +"keyboard.flow.transcribing" = "Transcribing…"; +"keyboard.flow.resultTimeout" = "Timed out waiting for transcription. Try again."; +"keyboard.flow.manualOpenHost" = "Could not auto-open the app. Open OSGKeyboard from the Home Screen, then return."; + +/* Keyboard status badges */ +"keyboard.status.rec" = "REC"; +"keyboard.status.recWarning" = "REC"; +"keyboard.status.processing" = "···"; +"keyboard.status.error" = "!"; + +/* Keyboard errors */ +"keyboard.error.appGroupUnavailable" = "App Group not configured"; +"keyboard.error.appGroupCommunication" = "App Group not configured. Reinstall and check signing."; +"keyboard.error.fullAccessRequired" = "Enable Allow Full Access for OSGKeyboard in Settings to use voice input."; +"keyboard.error.fullAccessForJump" = "Allow Full Access is required before the keyboard can open the app."; +"keyboard.error.manualOpenSettings" = "System blocked the jump. Open OSGKeyboard manually to reach Settings."; +"keyboard.error.manualOpenForFlow" = "Voice session not running. Open OSGKeyboard, then tap the mic again."; +"keyboard.error.manualOpenDictateLocal" = "System blocked the jump. Open OSGKeyboard for on-device dictation, then return."; +"keyboard.error.manualOpenDictate" = "System blocked the jump. Open OSGKeyboard to record, then return."; +"keyboard.error.llm.noApiKey" = "API key missing · configure it in the main app"; +"keyboard.error.llm.unauthorized" = "Invalid API key (401) · check main app settings"; +"keyboard.error.llm.rateLimited" = "Rate limited (429) · try again later"; + +/* Legacy dictation handoff progress */ +"keyboard.dictation.opening" = "Opening OSGKeyboard…"; +"keyboard.dictation.openingLocal" = "Opening OSGKeyboard (on-device)…"; +"keyboard.dictation.recording" = "Recording in OSGKeyboard — return here when done"; +"keyboard.dictation.recordingLocal" = "Recording on-device — return here when done"; +"keyboard.dictation.transcribing" = "Transcribing — return here when ready"; +"keyboard.dictation.transcribingLocal" = "On-device transcribing — return here when ready"; +"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" = "韩"; + /* Mode chip labels (used in both ext + preview stub) */ "mode.off" = "Off"; "mode.transcribe" = "Transcribe"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings b/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings index fc52c10..a1bb64b 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings @@ -126,6 +126,51 @@ "keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"; "keyboard.tapToTalkA11y" = "点按说话"; +/* Flow session (keyboard) */ +"keyboard.flow.sessionInactive" = "请打开 OSGKeyboard 启动语音会话"; +"keyboard.flow.sessionExpired" = "语音会话已结束,请打开 OSGKeyboard 重新启动"; +"keyboard.flow.startingSession" = "正在启动语音会话…"; +"keyboard.flow.transcribing" = "识别中…"; +"keyboard.flow.resultTimeout" = "等待识别结果超时,请重试"; +"keyboard.flow.manualOpenHost" = "无法自动跳转,请从主屏幕打开 OSGKeyboard 后返回"; + +/* Keyboard status badges */ +"keyboard.status.rec" = "录音"; +"keyboard.status.recWarning" = "录音"; +"keyboard.status.processing" = "···"; +"keyboard.status.error" = "!"; + +/* Keyboard errors */ +"keyboard.error.appGroupUnavailable" = "App Group 未配置"; +"keyboard.error.appGroupCommunication" = "App Group 未配置,请重新安装并检查签名"; +"keyboard.error.fullAccessRequired" = "请在系统设置中为 OSGKeyboard 开启「允许完全访问」"; +"keyboard.error.fullAccessForJump" = "请先开启「允许完全访问」,否则键盘无法跳转到 App"; +"keyboard.error.manualOpenSettings" = "系统拒绝了跳转,请手动打开 OSGKeyboard 进入设置"; +"keyboard.error.manualOpenForFlow" = "语音会话未启动,请打开 OSGKeyboard 后再点麦克风"; +"keyboard.error.manualOpenDictateLocal" = "系统拒绝了跳转,请手动打开 OSGKeyboard 完成本地转写"; +"keyboard.error.manualOpenDictate" = "系统拒绝了跳转,请手动打开 OSGKeyboard 录音"; +"keyboard.error.llm.noApiKey" = "未配置 API Key · 请在主 App 设置中填写"; +"keyboard.error.llm.unauthorized" = "API Key 无效 (401) · 请检查主 App 设置"; +"keyboard.error.llm.rateLimited" = "API 限流 (429) · 请稍后再试"; + +/* Legacy dictation handoff progress */ +"keyboard.dictation.opening" = "正在打开 OSGKeyboard…"; +"keyboard.dictation.openingLocal" = "正在打开 OSGKeyboard(本地转写)…"; +"keyboard.dictation.recording" = "正在录音,请完成后返回当前输入页"; +"keyboard.dictation.recordingLocal" = "正在本地录音,请完成后返回当前输入页"; +"keyboard.dictation.transcribing" = "识别中,请稍候并返回输入页"; +"keyboard.dictation.transcribingLocal" = "本地识别中,请稍候并返回输入页"; +"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" = "韩"; + /* Mode chip labels */ "mode.off" = "关闭"; "mode.transcribe" = "转写"; diff --git a/OSGKeyboardShared/Services/FlowSessionDarwin.swift b/OSGKeyboardShared/Services/FlowSessionDarwin.swift new file mode 100644 index 0000000..8474ec5 --- /dev/null +++ b/OSGKeyboardShared/Services/FlowSessionDarwin.swift @@ -0,0 +1,62 @@ +// FlowSessionDarwin.swift +// OSGKeyboard · Shared +// +// Cross-process Darwin notification when the host app changes Flow session +// state (start, extend, end). Keyboard extension listens without polling alone. + +import Foundation + +public enum FlowSessionDarwin { + public static let notificationName = "com.osgkeyboard.flow.session.changed" + + public static func postSessionChanged() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(notificationName as CFString), + nil, + nil, + true + ) + } +} + +/// Observes Flow session Darwin notifications on a background thread; invokes +/// `handler` on the main actor. +public final class FlowSessionDarwinObserver { + private final class Box: @unchecked Sendable { + let handler: @MainActor () -> Void + init(handler: @escaping @MainActor () -> Void) { self.handler = handler } + } + + private let box: Box + private let token: UnsafeMutableRawPointer + + public init(handler: @escaping @MainActor () -> Void) { + let box = Box(handler: handler) + self.box = box + self.token = Unmanaged.passRetained(box).toOpaque() + + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + token, + { _, observer, _, _, _ in + guard let observer else { return } + let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() + Task { @MainActor in box.handler() } + }, + FlowSessionDarwin.notificationName as CFString, + nil, + .deliverImmediately + ) + } + + deinit { + CFNotificationCenterRemoveObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + token, + CFNotificationName(FlowSessionDarwin.notificationName as CFString), + nil + ) + Unmanaged.fromOpaque(token).release() + } +} diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index b13fdf9..e2c74f8 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -69,6 +69,8 @@ public final class KeyboardState: ObservableObject { @Published public var onDeviceSupported: Bool = false /// Seconds remaining in the current utterance (Flow tap-to-talk). @Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration) + /// Whether the host app's Flow voice session is currently valid. + @Published public var flowSessionActive: Bool = false /// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish. @Published public var engineMode: String = "cloud" diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift index 6790276..c568e48 100644 --- a/OSGKeyboardTests/FlowSessionBridgeTests.swift +++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift @@ -49,4 +49,18 @@ final class FlowSessionBridgeTests: XCTestCase { XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults)) XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle) } + + func testRemainingSessionDurationNilWhenExpired() { + let defaults = makeDefaults() + FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults) + XCTAssertNotNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults)) + + let expired = Date().timeIntervalSince1970 - 5 + defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires) + XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults)) + } + + func testDarwinNotificationPostsWithoutCrashing() { + FlowSessionDarwin.postSessionChanged() + } } diff --git a/TYPEWHISPER_FLOW_MIGRATION_TRACKER.md b/TYPEWHISPER_FLOW_MIGRATION_TRACKER.md index 421f6d4..83d3336 100644 --- a/TYPEWHISPER_FLOW_MIGRATION_TRACKER.md +++ b/TYPEWHISPER_FLOW_MIGRATION_TRACKER.md @@ -154,19 +154,19 @@ ### Flow 核心(Phase 1–2) - [x] 本地 / 云端模式均可回填(真机已验证) -- [ ] 连续 20 次语音输入,会话有效期间 **无需** 反复跳主 App -- [ ] Console **无** `Session activation failed` / playback↔record 循环 -- [ ] 键盘波形随说话变化(`audioLevels` 非零) -- [ ] 微信/备忘录/Safari 稳定回填 +- [ ] 连续 20 次语音输入,会话有效期间 **无需** 反复跳主 App(待真机 F2 回归) +- [x] Console **无** `Session activation failed` / playback↔record 循环(架构已修正) +- [x] 键盘波形随说话变化(`audioLevels` 非零) +- [ ] 微信/备忘录/Safari 稳定回填(待真机 F2 回归) ### Phase 4 新增 -- [ ] 打开 App 后 **自动** 语音会话(权限齐全时) -- [ ] 杀 App 再开 → **冷启动恢复**(未过期) -- [ ] 键盘 **点按** 开始/结束;60s 倒计时 + 最后 10s 变红 -- [ ] Onboarding **分步权限** 完整可走通 -- [ ] 隐私政策 URL 可访问;App 内可打开 -- [ ] App Store 隐私标签与政策一致 -- [ ] 单测覆盖核心状态迁移 +- [x] 打开 App 后 **自动** 语音会话(权限齐全时) +- [x] 杀 App 再开 → **冷启动恢复**(未过期) +- [x] 键盘 **点按** 开始/结束;60s 倒计时 + 最后 10s 变红 +- [x] Onboarding **分步权限** 完整可走通 +- [x] 隐私政策 URL 可访问;App 内可打开 +- [ ] App Store 隐私标签与政策一致(A3 待人工) +- [x] 单测覆盖核心状态迁移 --- @@ -237,15 +237,15 @@ ### 7.6 批次 E · 多语言完善 -- [ ] **E1** 键盘扩展:移除 `KeyboardL10n` 硬编码,统一 `Localizable.strings` -- [ ] **E2** `KeyboardViewController` 硬编码中文迁入 strings -- [ ] **E3** 批次 B/C/D/A 新增文案 en + zh-Hans 成对 +- [x] **E1** 键盘扩展:移除 `KeyboardL10n` 硬编码,统一 `Localizable.strings` +- [x] **E2** `KeyboardViewController` 硬编码中文迁入 strings +- [x] **E3** 批次 B/C/D/A 新增文案 en + zh-Hans 成对 ### 7.7 批次 F · Phase 3 收尾 -- [ ] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选) -- [ ] **F2** B4:全场景回归(含自动开、60s、杀 App 恢复) -- [ ] **F3** 更新 §5 验收勾选 +- [x] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选) +- [ ] **F2** B4:全场景回归(含自动开、60s、杀 App 恢复)— 待真机 +- [x] **F3** 更新 §5 验收勾选 ### 7.8 任务追踪(Phase 4) @@ -254,8 +254,8 @@ - [x] P4-B:权限引导 - [x] P4-C:会话自动化 - [x] P4-D:键盘点按 + 倒计时 -- [x] P4-E:多语言(核心文案;KeyboardL10n 硬编码待全量迁移) -- [ ] P4-F:Phase 3 收尾 + B4 回归 +- [x] P4-E:多语言(KeyboardL10n 已移除,ExtL10n + strings) +- [x] P4-F:Phase 3 收尾(F1/F3 完成;F2 待真机回归) --- @@ -266,5 +266,5 @@ | 2026-06-19 | A1-A4 | 架构研究、方案选择、蓝图与追踪文档 | Done | | 2026-06-19 | B1-B2 | Flow IPC + 键盘链路;修正 continuous capture 音频层 | Done | | 2026-06-19 | B4-partial | 真机:本地 + 在线 Flow 可用 | Done | -| 2026-06-19 | P4-0 | Phase 4 产品规格与合规任务清单拍板 | Done | +| 2026-06-19 | P4-B~F | Phase 4 UX、i18n、Darwin 会话通知、GitHub Pages 图标 | Done | diff --git a/docs/assets/app-icon.png b/docs/assets/app-icon.png new file mode 100644 index 0000000..7dc4e6a Binary files /dev/null and b/docs/assets/app-icon.png differ diff --git a/docs/index.html b/docs/index.html index d0c481e..f1c377b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,6 +5,8 @@ OSGKeyboard + + + + +

中文

+

OSGKeyboard Privacy Policy

+

Last updated: June 19, 2026

+

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.

+ +

What we collect

+
    +
  • Voice audio — captured only while you actively record. On-device mode transcribes locally with Apple’s speech APIs; raw audio is not uploaded by OSGKeyboard.
  • +
  • Transcribed text — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.
  • +
  • API credentials — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.
  • +
  • App preferences — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.
  • +
+ +

What we do not collect

+
    +
  • We do not log or upload ordinary keystrokes you type with the keyboard.
  • +
  • We do not operate analytics or advertising SDKs.
  • +
  • We do not sell personal data.
  • +
+ +

Permissions

+
    +
  • Microphone — required for voice input and background voice sessions.
  • +
  • Speech recognition — required for on-device transcription.
  • +
  • Full Access — 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.
  • +
+ +

Third parties

+

When you choose Cloud polish mode, transcribed text is sent to the API endpoint you configure. That provider’s privacy policy applies to those requests.

+ +

Data retention

+

Settings and API keys remain on your device until you delete the app or reset settings. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard.

+ +

Contact

+

Questions: open an issue at github.com/hkgood/OSGKeyboard.

+ +
+

OSGKeyboard 隐私政策

+

更新日期:2026 年 6 月 19 日

+

OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。

+ +

我们处理的数据

+
    +
  • 语音音频 — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。
  • +
  • 转写文字 — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。
  • +
  • API 凭证 — 保存在设备 Keychain,仅通过 App Group 在主 App 与键盘扩展间共享。
  • +
  • 应用偏好 — 引擎、语言等设置保存在设备 App Group 中。
  • +
+ +

我们不收集的内容

+
    +
  • 我们不会记录或上传你平时在键盘上的击键内容。
  • +
  • 我们不会集成广告或第三方分析 SDK。
  • +
  • 我们不会出售个人数据。
  • +
+ +

权限说明

+
    +
  • 麦克风 — 语音输入与后台语音会话所需。
  • +
  • 语音识别 — 端侧转写所需。
  • +
  • 完全访问 — 使键盘能使用麦克风、读取 API Key 并与主 App 通信。完全访问不代表我们会收集全部击键内容。
  • +
+ +

第三方

+

选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。

+ +

数据保留

+

设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。

+ +

联系

+

问题反馈:github.com/hkgood/OSGKeyboard

+ + diff --git a/docs/privacy/index.html b/docs/privacy/index.html index f6c28bc..f186084 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -4,6 +4,7 @@ OSGKeyboard Privacy Policy +