feat(macos): add macOS menu-bar app and harden cross-device iCloud sync
Introduce a standalone macOS menu-bar app (OSGKeyboardMac) that reuses the platform-agnostic OSGKeyboardShared core: record -> cloud/local ASR -> polish -> insert. Local mode uses Qwen3-ASR via mlx-swift-asr (macOS 15+, Apple Silicon); iOS targets stay zero-SPM. Harden iCloud sync for multi-device correctness: - Per-field settings merge (appSettings.v2) so concurrent edits no longer clobber each other's unrelated fields. - Per-device usage statistics (G-Counter) that sum instead of max(). - Tombstoned dictionary/history merge so deletes propagate and entries can't resurrect. - API keys replicate via iCloud Keychain, never iCloud KVS JSON; pulling a legacy blob without key fields no longer wipes local Keychain entries. - Add a low-risk "Sync Now" action in Settings. Fix Flow keyboard mic state: stay orange until the host publishes a real ready contract, share a single MicVoiceAvailability gate, and self-heal stale cross-process heartbeat jitter instead of getting stuck. Extract shared storage (SpeechHistoryStore/UsageStatisticsStore, ConfigurationStore) into OSGKeyboardShared and add tests for the new sync/merge logic.
This commit is contained in:
@@ -31,8 +31,6 @@
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : [
|
||||
"iOS"
|
||||
]
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "OSGLogoWide.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg width="952" height="291" viewBox="36 367 952 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
|
||||
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -22,15 +22,15 @@
|
||||
<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> June 19, 2026</p>
|
||||
<p><strong>Last updated:</strong> July 8, 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. On-device mode transcribes locally with Apple’s speech APIs; raw audio is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Transcribed text</strong> — 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.</li>
|
||||
<li><strong>API credentials</strong> — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.</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>
|
||||
</ul>
|
||||
|
||||
<h2>What we do not collect</h2>
|
||||
@@ -51,8 +51,8 @@
|
||||
<p>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.</p>
|
||||
|
||||
<h2>Data retention</h2>
|
||||
<p>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.</p>
|
||||
<p><strong>Voice history</strong> — successful transcripts may be saved locally in the main app’s History tab for your convenience. This history stays on your device only, is never uploaded, and can be cleared at any time from History or by resetting settings.</p>
|
||||
<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>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
||||
@@ -60,15 +60,15 @@
|
||||
|
||||
<hr id="zh">
|
||||
<h1>OSGKeyboard 隐私政策</h1>
|
||||
<p><strong>更新日期:</strong>2026 年 6 月 19 日</p>
|
||||
<p><strong>更新日期:</strong>2026 年 7 月 8 日</p>
|
||||
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
|
||||
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。</li>
|
||||
<li><strong>转写文字</strong> — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,仅通过 App Group 在主 App 与键盘扩展间共享。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在设备 App Group 中。</li>
|
||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像偏好、统计与语音历史。</li>
|
||||
</ul>
|
||||
|
||||
<h2>我们不收集的内容</h2>
|
||||
@@ -89,8 +89,8 @@
|
||||
<p>选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。</p>
|
||||
|
||||
<h2>数据保留</h2>
|
||||
<p>设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。</p>
|
||||
<p><strong>语音历史</strong> — 成功的转写可能保存在主 App「历史」页,仅供本机查看,不会上传,可随时在历史页清空或通过重置设置清除。</p>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;偏好、统计与历史可能经私有 iCloud 账户同步。</p>
|
||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||
|
||||
<h2>联系</h2>
|
||||
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
||||
|
||||
@@ -67,12 +67,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||
private var isColdStartHandoff = false
|
||||
private static let coldStartAudioProofTimeout: TimeInterval = 2.5
|
||||
|
||||
init() {
|
||||
// Sessions are (re)started explicitly on app foreground via
|
||||
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||
// stored session here — after a force-quit that would resurrect capture
|
||||
// (and keep a stale Live Activity alive) without the user re-opening.
|
||||
capture.onEngineLiveChanged = { [weak self] _ in
|
||||
self?.refreshHostReady()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -86,15 +90,17 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
if coldStart {
|
||||
isColdStartHandoff = true
|
||||
showColdStartPreparing()
|
||||
}
|
||||
|
||||
reconcilePersistedFlowStateBeforeStart()
|
||||
|
||||
if isActive {
|
||||
extendSession(duration: duration)
|
||||
refreshHostReady()
|
||||
if coldStart {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleColdStartAfterSessionReady()
|
||||
await self?.prepareExistingSessionForColdStartReturn()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -144,6 +150,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
if isColdStartHandoff {
|
||||
showColdStartPermissionFailure()
|
||||
}
|
||||
FlowLiveActivityController.endSession()
|
||||
return
|
||||
}
|
||||
@@ -151,6 +161,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
func dismissColdStartOverlay() {
|
||||
guard coldStartContext?.state != .preparing else { return }
|
||||
if isActive {
|
||||
refreshHostReady()
|
||||
}
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
}
|
||||
@@ -160,11 +174,21 @@ final class FlowSessionManager: ObservableObject {
|
||||
dismissColdStartOverlay()
|
||||
}
|
||||
|
||||
func retryColdStartReadiness() {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
startSession(coldStart: true)
|
||||
}
|
||||
|
||||
func openColdStartPermissionSettings() {
|
||||
AppPermissions.openSystemSettings()
|
||||
}
|
||||
|
||||
func endSession() {
|
||||
guard isActive else { return }
|
||||
debug("Flow session ended")
|
||||
|
||||
dismissColdStartOverlay()
|
||||
coldStartContext = nil
|
||||
isColdStartHandoff = false
|
||||
startTask?.cancel()
|
||||
startTask = nil
|
||||
pollingTask?.cancel()
|
||||
@@ -282,19 +306,62 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
if capture.running {
|
||||
capture.reassertIfRunning()
|
||||
if capture.engineHasRecentAudio() {
|
||||
sessionWarning = nil
|
||||
}
|
||||
refreshHostReady()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try capture.start()
|
||||
sessionWarning = nil
|
||||
debug("capture restarted after foreground")
|
||||
refreshHostReady()
|
||||
} catch {
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
sessionWarning = message
|
||||
debug("capture restart failed: \(message)")
|
||||
refreshHostReady()
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish whether the keyboard can start a new utterance without jumping to the host app.
|
||||
private func refreshHostReady() {
|
||||
guard isActive else {
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
return
|
||||
}
|
||||
|
||||
let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
|
||||
// Steady-state ready uses structural engine liveness. The stricter
|
||||
// "recent audio frame" proof is reserved for cold-start handoff only
|
||||
// (`waitForAudioProof`) so brief UI-driven session hiccups do not
|
||||
// drop the keyboard back to orange while the host app is foreground.
|
||||
let canAcceptUtterance = capture.engineIsLive
|
||||
&& pollingAlive
|
||||
&& !isUtteranceRecording
|
||||
&& !isUtteranceProcessing
|
||||
&& sessionWarning == nil
|
||||
|
||||
FlowSessionBridge.setHostReady(canAcceptUtterance)
|
||||
}
|
||||
|
||||
/// Home preview field gained focus while this app is the Flow host.
|
||||
/// Reactivate capture and refresh the App Group ready contract so the
|
||||
/// custom keyboard extension sees green immediately.
|
||||
func refreshForInlineKeyboardFocus() async {
|
||||
guard isActive else { return }
|
||||
await reactivateCaptureIfNeeded()
|
||||
refreshHostReady()
|
||||
if !FlowSessionBridge.isHostReady() {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
await reactivateCaptureIfNeeded()
|
||||
refreshHostReady()
|
||||
}
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
}
|
||||
|
||||
/// Extend expiry after utterance completion based on the inactivity policy.
|
||||
private func touchSessionActivity() {
|
||||
guard isActive else { return }
|
||||
@@ -317,6 +384,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
sessionWarning = permissionWarningMessage()
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
if isColdStartHandoff {
|
||||
showColdStartPermissionFailure()
|
||||
}
|
||||
isColdStartHandoff = false
|
||||
return
|
||||
}
|
||||
@@ -326,11 +397,26 @@ final class FlowSessionManager: ObservableObject {
|
||||
} catch {
|
||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
sessionWarning = message
|
||||
isColdStartHandoff = false
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
if isColdStartHandoff {
|
||||
showColdStartAudioFailure(message: message)
|
||||
}
|
||||
debug("continuous capture failed: \(message)")
|
||||
return
|
||||
}
|
||||
|
||||
guard await waitForAudioProof() else {
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
capture.stop()
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
if isColdStartHandoff {
|
||||
showColdStartAudioFailure(message: message)
|
||||
}
|
||||
debug("continuous capture did not produce audio frames before timeout")
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
|
||||
FlowSessionBridge.markSessionActive(duration: resolvedDuration)
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
@@ -347,22 +433,79 @@ final class FlowSessionManager: ObservableObject {
|
||||
scheduleASRWarmup()
|
||||
FlowLiveActivityController.startSession()
|
||||
|
||||
refreshHostReady()
|
||||
debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
|
||||
}
|
||||
|
||||
private func prepareExistingSessionForColdStartReturn() async {
|
||||
guard isColdStartHandoff, isActive else { return }
|
||||
await reactivateCaptureIfNeeded()
|
||||
guard await waitForAudioProof() else {
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
showColdStartAudioFailure(message: message)
|
||||
debug("existing session failed cold-start audio proof")
|
||||
return
|
||||
}
|
||||
sessionWarning = nil
|
||||
refreshHostReady()
|
||||
handleColdStartAfterSessionReady()
|
||||
}
|
||||
|
||||
private func waitForAudioProof() async -> Bool {
|
||||
await capture.awaitAudioFlowing(timeout: Self.coldStartAudioProofTimeout)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleColdStartAfterSessionReady() {
|
||||
guard isColdStartHandoff, isActive else { return }
|
||||
|
||||
let hostEntry = HostReturnService.pendingHostEntry()
|
||||
let skipSwitch = FlowSessionPolicy.skipAppSwitch()
|
||||
|
||||
if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() {
|
||||
dismissColdStartOverlay()
|
||||
refreshHostReady()
|
||||
guard FlowSessionBridge.isHostReady() else {
|
||||
let message = AppL10n.string("flow.coldStart.error.audioTimeout")
|
||||
sessionWarning = message
|
||||
showColdStartAudioFailure(message: message)
|
||||
debug("cold-start blocked: host ready contract not published")
|
||||
return
|
||||
}
|
||||
|
||||
coldStartContext = FlowColdStartContext(hostEntry: hostEntry)
|
||||
let hostEntry = HostReturnService.pendingHostEntry()
|
||||
let skipSwitch = FlowSessionPolicy.skipAppSwitch()
|
||||
coldStartContext = FlowColdStartContext(hostEntry: hostEntry, state: .ready)
|
||||
|
||||
if skipSwitch, hostEntry != nil {
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
guard let self, self.coldStartContext?.state == .ready else { return }
|
||||
if HostReturnService.openPendingHostIfPossible() {
|
||||
self.dismissColdStartOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func showColdStartPreparing() {
|
||||
coldStartContext = FlowColdStartContext(
|
||||
hostEntry: HostReturnService.pendingHostEntry(),
|
||||
state: .preparing
|
||||
)
|
||||
}
|
||||
|
||||
private func showColdStartPermissionFailure() {
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
coldStartContext = FlowColdStartContext(
|
||||
hostEntry: HostReturnService.pendingHostEntry(),
|
||||
state: .failed(.permission(message: permissionWarningMessage()))
|
||||
)
|
||||
}
|
||||
|
||||
private func showColdStartAudioFailure(message: String) {
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
coldStartContext = FlowColdStartContext(
|
||||
hostEntry: HostReturnService.pendingHostEntry(),
|
||||
state: .failed(.audio(message: message))
|
||||
)
|
||||
}
|
||||
|
||||
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||
@@ -434,8 +577,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard !isUtteranceRecording, !isUtteranceProcessing else { return }
|
||||
beginUtterance()
|
||||
case .stopped:
|
||||
guard isUtteranceRecording else { return }
|
||||
endUtterance()
|
||||
if isUtteranceRecording {
|
||||
endUtterance()
|
||||
} else if !isUtteranceProcessing {
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowSessionBridge.storeTranscriptionError(
|
||||
AppL10n.string("flow.error.recognitionInterrupted"),
|
||||
kind: .recognitionInterrupted
|
||||
)
|
||||
debug("stopped without active utterance — notified keyboard")
|
||||
}
|
||||
case .aborted:
|
||||
abortUtterance()
|
||||
case .idle, .processing:
|
||||
@@ -444,7 +595,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func beginUtterance() {
|
||||
guard capture.running else {
|
||||
guard capture.engineIsLive else {
|
||||
failUtterance(
|
||||
message: AppL10n.string("flow.error.audioUnavailable"),
|
||||
kind: .audioUnavailable
|
||||
@@ -474,6 +625,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
isUtteranceRecording = true
|
||||
utteranceRecordingStartedAt = Date()
|
||||
refreshHostReady()
|
||||
FlowLiveActivityController.update(phase: .recording)
|
||||
FlowDiagnostics.log(
|
||||
"beginUtterance engine=\(store.engineMode) " +
|
||||
@@ -528,6 +680,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.setRecordingState(.processing)
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = true
|
||||
refreshHostReady()
|
||||
FlowLiveActivityController.update(phase: .processing)
|
||||
|
||||
// Do NOT cancel `asrTask` or `asr` — drain trailing PCM, then finalize.
|
||||
@@ -559,6 +712,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
refreshHostReady()
|
||||
debug("utterance aborted")
|
||||
}
|
||||
|
||||
@@ -583,6 +737,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
refreshHostReady()
|
||||
debug("utterance failed: \(message)")
|
||||
}
|
||||
|
||||
@@ -602,6 +757,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
refreshHostReady()
|
||||
debug("utterance processing failed: \(message)")
|
||||
}
|
||||
|
||||
@@ -612,6 +768,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
FlowLiveActivityController.update(phase: .idle)
|
||||
touchSessionActivity()
|
||||
refreshHostReady()
|
||||
}
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
@@ -808,9 +965,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
heartbeatTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { break }
|
||||
if self.isActive, !self.capture.engineIsLive {
|
||||
await self.reactivateCaptureIfNeeded()
|
||||
}
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
self.refreshHostReady()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard self?.isActive == true else { break }
|
||||
guard self.isActive else { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SpeechHistoryStore+iOS.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// iOS-only helper that records history and home-screen usage stats together.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
extension SpeechHistoryStore {
|
||||
/// Append history and update cumulative home-screen usage stats.
|
||||
func recordUtterance(
|
||||
text: String,
|
||||
engineMode: String,
|
||||
duration: TimeInterval,
|
||||
wasTranslation: Bool
|
||||
) {
|
||||
append(text: text, engineMode: engineMode)
|
||||
UsageStatisticsStore.shared.recordUtterance(
|
||||
text: text,
|
||||
duration: duration,
|
||||
wasTranslation: wasTranslation
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// SpeechHistoryStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Local-only log of successful voice transcriptions (Flow + future paths).
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct SpeechHistoryEntry: Codable, Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let text: String
|
||||
let createdAt: Date
|
||||
let engineMode: String
|
||||
|
||||
init(id: UUID = UUID(), text: String, createdAt: Date = Date(), engineMode: String) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.engineMode = engineMode
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SpeechHistoryStore: ObservableObject {
|
||||
static let shared = SpeechHistoryStore()
|
||||
|
||||
@Published private(set) var entries: [SpeechHistoryEntry] = []
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "speechHistory.entries.v1"
|
||||
private let maxEntries = 500
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
load()
|
||||
}
|
||||
|
||||
func append(text: String, engineMode: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
|
||||
entries.insert(entry, at: 0)
|
||||
if entries.count > maxEntries {
|
||||
entries = Array(entries.prefix(maxEntries))
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Append history and update cumulative home-screen usage stats.
|
||||
func recordUtterance(
|
||||
text: String,
|
||||
engineMode: String,
|
||||
duration: TimeInterval,
|
||||
wasTranslation: Bool
|
||||
) {
|
||||
append(text: text, engineMode: engineMode)
|
||||
UsageStatisticsStore.shared.recordUtterance(
|
||||
text: text,
|
||||
duration: duration,
|
||||
wasTranslation: wasTranslation
|
||||
)
|
||||
}
|
||||
|
||||
func delete(id: UUID) {
|
||||
guard entries.contains(where: { $0.id == id }) else { return }
|
||||
entries.removeAll { $0.id == id }
|
||||
persist()
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
entries.removeAll()
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Entries grouped by calendar day (newest day first).
|
||||
var groupedByDay: [(day: Date, items: [SpeechHistoryEntry])] {
|
||||
let calendar = Calendar.current
|
||||
var buckets: [Date: [SpeechHistoryEntry]] = [:]
|
||||
for entry in entries {
|
||||
let day = calendar.startOfDay(for: entry.createdAt)
|
||||
buckets[day, default: []].append(entry)
|
||||
}
|
||||
return buckets.keys.sorted(by: >).map { day in
|
||||
(day, buckets[day]!.sorted { $0.createdAt > $1.createdAt })
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = defaults.data(forKey: storageKey) else { return }
|
||||
do {
|
||||
entries = try JSONDecoder().decode([SpeechHistoryEntry].self, from: data)
|
||||
} catch {
|
||||
entries = []
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let data = try? JSONEncoder().encode(entries) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// UsageStatisticsStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Cumulative usage metrics shown on the home screen stats card.
|
||||
// Updated when Flow finalizes a successful utterance.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct UsageStatistics: Codable, Equatable {
|
||||
var dictationDurationSeconds: TimeInterval
|
||||
var dictationCharacterCount: Int
|
||||
var translationCharacterCount: Int
|
||||
|
||||
static let zero = UsageStatistics(
|
||||
dictationDurationSeconds: 0,
|
||||
dictationCharacterCount: 0,
|
||||
translationCharacterCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UsageStatisticsStore: ObservableObject {
|
||||
static let shared = UsageStatisticsStore()
|
||||
|
||||
@Published private(set) var dictationDurationSeconds: TimeInterval = 0
|
||||
@Published private(set) var dictationCharacterCount: Int = 0
|
||||
@Published private(set) var translationCharacterCount: Int = 0
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "usageStatistics.v1"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
load()
|
||||
}
|
||||
|
||||
func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
let count = Self.characterCount(for: trimmed)
|
||||
if wasTranslation {
|
||||
translationCharacterCount += count
|
||||
} else {
|
||||
dictationCharacterCount += count
|
||||
}
|
||||
dictationDurationSeconds += max(0, duration)
|
||||
persist()
|
||||
}
|
||||
|
||||
static func characterCount(for text: String) -> Int {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines).count
|
||||
}
|
||||
|
||||
// MARK: - Formatting
|
||||
|
||||
static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
|
||||
let total = max(0, Int(seconds.rounded()))
|
||||
if total < 60 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(total)秒"
|
||||
: "\(total)s"
|
||||
}
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
if hours > 0 {
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(hours)小时\(minutes)分"
|
||||
: "\(hours)h \(minutes)m"
|
||||
}
|
||||
return language.resolvedLanguageCode().hasPrefix("zh")
|
||||
? "\(minutes)分"
|
||||
: "\(minutes)m"
|
||||
}
|
||||
|
||||
static func formatCount(_ value: Int, language: AppUILanguage) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
|
||||
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = defaults.data(forKey: storageKey),
|
||||
let stats = try? JSONDecoder().decode(UsageStatistics.self, from: data)
|
||||
else { return }
|
||||
dictationDurationSeconds = stats.dictationDurationSeconds
|
||||
dictationCharacterCount = stats.dictationCharacterCount
|
||||
translationCharacterCount = stats.translationCharacterCount
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let stats = UsageStatistics(
|
||||
dictationDurationSeconds: dictationDurationSeconds,
|
||||
dictationCharacterCount: dictationCharacterCount,
|
||||
translationCharacterCount: translationCharacterCount
|
||||
)
|
||||
guard let data = try? JSONEncoder().encode(stats) else { return }
|
||||
defaults.set(data, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,12 @@ struct HomeStatsCard: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
|
||||
refreshDictionaryCount()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
|
||||
stats.reloadFromDisk()
|
||||
}
|
||||
}
|
||||
|
||||
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
|
||||
|
||||
@@ -1,13 +1,29 @@
|
||||
// FlowColdStartOverlay.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Minimal cold-start handoff UI: bottom-bar swipe guidance and optional return link.
|
||||
// Cold-start handoff hint: a bottom-anchored, full-width gradient that keeps
|
||||
// the current app visible while Flow proves that voice input is actually
|
||||
// ready. Failure states reuse the same minimal layout and only change the
|
||||
// text — permission issues are handled with a single "open Settings" link,
|
||||
// never a second in-app permission flow.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct FlowColdStartContext: Equatable {
|
||||
let hostEntry: HostAppEntry?
|
||||
var state: FlowColdStartState
|
||||
}
|
||||
|
||||
enum FlowColdStartState: Equatable {
|
||||
case preparing
|
||||
case ready
|
||||
case failed(FlowColdStartFailure)
|
||||
}
|
||||
|
||||
enum FlowColdStartFailure: Equatable {
|
||||
case permission(message: String)
|
||||
case audio(message: String)
|
||||
}
|
||||
|
||||
struct FlowColdStartOverlay: View {
|
||||
@@ -16,105 +32,155 @@ struct FlowColdStartOverlay: View {
|
||||
let context: FlowColdStartContext
|
||||
let onReturnToHost: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
let onRetry: () -> Void
|
||||
let onOpenSettings: () -> Void
|
||||
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
|
||||
private let homeBarWidth: CGFloat = 134
|
||||
/// Fraction of the screen height the bottom gradient occupies.
|
||||
private let gradientHeightFraction: CGFloat = 0.50
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.opacity(0.96)
|
||||
.ignoresSafeArea()
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .bottom) {
|
||||
// Full-width bottom gradient: transparent at the top of the
|
||||
// band, nearly opaque at the bottom so hint text stays readable.
|
||||
LinearGradient(
|
||||
colors: [
|
||||
palette.background.opacity(0.35),
|
||||
palette.background.opacity(0.72),
|
||||
palette.background.opacity(0.97)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: geo.size.height * gradientHeightFraction)
|
||||
.frame(maxWidth: .infinity, alignment: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
VStack(spacing: Spacing.lg) {
|
||||
content
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Image("OSGBrandMark")
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("flow.coldStart.title")
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("flow.coldStart.swipeHint")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if context.hostEntry != nil {
|
||||
Button(action: onReturnToHost) {
|
||||
Text(returnButtonTitle)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
homeIndicator
|
||||
.padding(.bottom, max(geo.safeAreaInsets.bottom, Spacing.sm))
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
Spacer()
|
||||
|
||||
bottomSwipeGuide
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
Text("flow.coldStart.tapToDismiss")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.padding(.bottom, Spacing.xl)
|
||||
if context.state == .ready {
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
.animation(.easeInOut(duration: 0.2), value: context.state)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var returnButtonTitle: String {
|
||||
guard let entry = context.hostEntry else {
|
||||
return AppL10n.string("flow.coldStart.return.generic")
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
statusIcon
|
||||
|
||||
Text(title)
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(message)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
actionLink
|
||||
}
|
||||
let appName = AppL10n.string(entry.displayNameKey)
|
||||
return AppL10n.format("flow.coldStart.return.named", appName)
|
||||
}
|
||||
|
||||
private var bottomSwipeGuide: some View {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2.5, style: .continuous)
|
||||
.fill(palette.textTertiary.opacity(0.35))
|
||||
.frame(width: homeBarWidth, height: 5)
|
||||
|
||||
Circle()
|
||||
.fill(palette.accent)
|
||||
.frame(width: 8, height: 8)
|
||||
.offset(x: swipeOffset)
|
||||
}
|
||||
.frame(width: homeBarWidth, height: 16)
|
||||
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "arrow.left")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
.scaleEffect(1.1)
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.preparing"))
|
||||
case .ready:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(palette.warning)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
|
||||
.onAppear {
|
||||
swipeOffset = 0
|
||||
withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) {
|
||||
swipeOffset = homeBarWidth - 8
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionLink: some View {
|
||||
switch context.state {
|
||||
case .preparing, .ready:
|
||||
EmptyView()
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission:
|
||||
linkButton(AppL10n.string("flow.coldStart.action.settings"), action: onOpenSettings)
|
||||
case .audio:
|
||||
linkButton(AppL10n.string("flow.coldStart.action.retry"), action: onRetry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func linkButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
return AppL10n.string("flow.coldStart.preparing")
|
||||
case .ready:
|
||||
return AppL10n.string("flow.coldStart.title")
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission:
|
||||
return AppL10n.string("flow.coldStart.permission.title")
|
||||
case .audio:
|
||||
return AppL10n.string("flow.coldStart.audio.title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
switch context.state {
|
||||
case .preparing:
|
||||
return AppL10n.string("flow.coldStart.preparingHint")
|
||||
case .ready:
|
||||
return AppL10n.string("flow.coldStart.swipeHint")
|
||||
case .failed(let failure):
|
||||
switch failure {
|
||||
case .permission(let message):
|
||||
return message
|
||||
case .audio(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// System-style home indicator — anchors the swipe-to-return gesture.
|
||||
private var homeIndicator: some View {
|
||||
Capsule()
|
||||
.fill(palette.textTertiary.opacity(context.state == .ready ? 0.55 : 0.35))
|
||||
.frame(width: 134, height: 5)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,10 @@ struct HomeView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
refreshPermissionStatuses()
|
||||
}
|
||||
.onChange(of: previewFocused) { _, focused in
|
||||
guard focused else { return }
|
||||
Task { await flowManager.refreshForInlineKeyboardFocus() }
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPermissionStatuses() {
|
||||
|
||||
@@ -29,7 +29,9 @@ struct MainAppRoot: View {
|
||||
FlowColdStartOverlay(
|
||||
context: context,
|
||||
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
|
||||
onDismiss: { flowManager.dismissColdStartOverlay() }
|
||||
onDismiss: { flowManager.dismissColdStartOverlay() },
|
||||
onRetry: { flowManager.retryColdStartReadiness() },
|
||||
onOpenSettings: { flowManager.openColdStartPermissionSettings() }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
|
||||
private func delete(_ entry: PersonalDictionary.Entry) {
|
||||
dictionary.entries.removeAll { $0.id == entry.id }
|
||||
dictionary.recordDeletion(of: entry.id)
|
||||
generatingAliasEntryIDs.remove(entry.id)
|
||||
persist()
|
||||
}
|
||||
@@ -275,7 +275,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
|
||||
private func clearAll() {
|
||||
dictionary = .empty
|
||||
dictionary.recordClearAll()
|
||||
generatingAliasEntryIDs = []
|
||||
persist()
|
||||
}
|
||||
|
||||
@@ -95,18 +95,3 @@ struct ProviderPickerSection: View {
|
||||
.background(palette.accentMuted, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
enum ProviderLogo {
|
||||
static func assetName(for providerId: String) -> String? {
|
||||
switch providerId {
|
||||
case "openai": return "openai"
|
||||
case "deepseek": return "deepseek"
|
||||
case "qwen": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Settings-row toggle for mirroring user preferences through iCloud KVS.
|
||||
// API keys remain in Keychain and are never uploaded.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
@@ -14,6 +13,7 @@ struct SettingsICloudSyncRow: View {
|
||||
@State private var isEnabled: Bool = AppGroupStore().settingsICloudSyncEnabled
|
||||
@State private var syncErrorMessage: String?
|
||||
@State private var isApplyingToggle = false
|
||||
@State private var isSyncingNow = false
|
||||
|
||||
private let store = AppGroupStore()
|
||||
|
||||
@@ -33,6 +33,25 @@ struct SettingsICloudSyncRow: View {
|
||||
.tint(palette.accent)
|
||||
.disabled(isApplyingToggle)
|
||||
|
||||
if isEnabled {
|
||||
Button {
|
||||
syncNow()
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isSyncingNow {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text("settings.appSettings.iCloudSync.syncNow")
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(palette.accent)
|
||||
.disabled(isSyncingNow || isApplyingToggle)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
|
||||
if let syncErrorMessage {
|
||||
Text(syncErrorMessage)
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -75,7 +94,7 @@ struct SettingsICloudSyncRow: View {
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await SettingsCloudSync.shared.enableSync()
|
||||
try await CloudSyncContext.shared.settingsSyncService.enableSync()
|
||||
reloadFromStore()
|
||||
} catch let error as SettingsCloudSyncError {
|
||||
isEnabled = false
|
||||
@@ -89,11 +108,24 @@ struct SettingsICloudSyncRow: View {
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
SettingsCloudSync.shared.disableSync()
|
||||
CloudSyncContext.shared.settingsSyncService.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
}
|
||||
|
||||
private func syncNow() {
|
||||
isSyncingNow = true
|
||||
syncErrorMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await CloudSyncContext.shared.syncNow()
|
||||
} catch {
|
||||
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic")
|
||||
}
|
||||
isSyncingNow = false
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedSyncError(_ error: SettingsCloudSyncError) -> String {
|
||||
switch error {
|
||||
case .encodeFailed, .decodeFailed:
|
||||
|
||||
@@ -369,8 +369,9 @@
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.title" = "Sync settings via iCloud";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Keep engine, language, polish, and Flow preferences in sync across your devices. API keys stay on each device.";
|
||||
"settings.appSettings.iCloudSync.title" = "iCloud Sync";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain.";
|
||||
"settings.appSettings.iCloudSync.syncNow" = "Sync Now";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
|
||||
|
||||
/* v0.3.0: Polish intensity */
|
||||
@@ -391,7 +392,14 @@
|
||||
|
||||
/* Cold-start handoff (scheme B) */
|
||||
"flow.coldStart.title" = "Voice is ready";
|
||||
"flow.coldStart.swipeHint" = "Swipe right along the bar at the bottom to return to your previous app.";
|
||||
"flow.coldStart.preparing" = "Getting voice ready";
|
||||
"flow.coldStart.preparingHint" = "Keep OSGKeyboard open for a moment while we start the microphone session.";
|
||||
"flow.coldStart.permission.title" = "Permission required";
|
||||
"flow.coldStart.audio.title" = "Voice could not start";
|
||||
"flow.coldStart.error.audioTimeout" = "The microphone did not become ready in time. It may be busy in another app. Please try again.";
|
||||
"flow.coldStart.action.settings" = "Open Settings";
|
||||
"flow.coldStart.action.retry" = "Try Again";
|
||||
"flow.coldStart.swipeHint" = "Swipe right along the bottom bar\nto return to your previous app.";
|
||||
"flow.coldStart.swipeAccessibility" = "Swipe right along the bottom bar to return";
|
||||
"flow.coldStart.tapToDismiss" = "Tap anywhere to close";
|
||||
"flow.coldStart.return.named" = "Return to %@";
|
||||
|
||||
@@ -368,8 +368,9 @@
|
||||
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
|
||||
|
||||
"settings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.title" = "设置 iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "在多台设备间同步引擎、语言、润色和 Flow 偏好。API 密钥仍保留在各设备本地。";
|
||||
"settings.appSettings.iCloudSync.title" = "iCloud 同步";
|
||||
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。";
|
||||
"settings.appSettings.iCloudSync.syncNow" = "立即同步";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
|
||||
|
||||
/* v0.3.0: 润色强度 */
|
||||
@@ -390,7 +391,14 @@
|
||||
|
||||
/* 冷启动兜底(方案 B) */
|
||||
"flow.coldStart.title" = "语音已就绪";
|
||||
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动,返回上一个 App。";
|
||||
"flow.coldStart.preparing" = "正在就绪";
|
||||
"flow.coldStart.preparingHint" = "请先停留片刻,我们正在启动麦克风会话。";
|
||||
"flow.coldStart.permission.title" = "需要权限";
|
||||
"flow.coldStart.audio.title" = "语音暂时无法启动";
|
||||
"flow.coldStart.error.audioTimeout" = "麦克风未能及时就绪,可能正被通话或其他 App 占用。请稍后重试。";
|
||||
"flow.coldStart.action.settings" = "前往设置";
|
||||
"flow.coldStart.action.retry" = "重试";
|
||||
"flow.coldStart.swipeHint" = "沿屏幕底部的横条,从左向右滑动\n返回上一个 App。";
|
||||
"flow.coldStart.swipeAccessibility" = "沿底部横条从左向右滑动返回";
|
||||
"flow.coldStart.tapToDismiss" = "点按屏幕关闭";
|
||||
"flow.coldStart.return.named" = "返回%@";
|
||||
|
||||
Reference in New Issue
Block a user