feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start, tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
This commit is contained in:
@@ -62,16 +62,19 @@ public struct AppGroupPersistor {
|
||||
|
||||
/// Persist `mode` to the App Group store.
|
||||
public func persist(mode: KeyboardViewController.State.InputMode) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setModeId(mode.rawValue)
|
||||
}
|
||||
|
||||
/// Persist `localeId` to the App Group store.
|
||||
public func persist(localeId: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setLocaleId(localeId)
|
||||
}
|
||||
|
||||
/// Persist `engineMode` to the App Group store.
|
||||
public func persist(engineMode: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setEngineMode(engineMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// HostAppLauncher.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Opens the host app via URL using every extension-safe strategy:
|
||||
// 1. `extensionContext.open` (official)
|
||||
// 2. Responder-chain `UIApplication.open` (TypeWhisper pattern)
|
||||
// 3. `sharedApplication` KVC fallback (common in full-access keyboards)
|
||||
|
||||
import UIKit
|
||||
|
||||
enum HostAppLauncher {
|
||||
@MainActor
|
||||
static func open(
|
||||
url: URL,
|
||||
from controller: KeyboardViewController,
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
if let context = controller.extensionContext {
|
||||
context.open(url) { success in
|
||||
Task { @MainActor in
|
||||
if success {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
completion(openViaFallback(url, from: controller))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(openViaFallback(url, from: controller))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaFallback(
|
||||
_ url: URL,
|
||||
from controller: KeyboardViewController
|
||||
) -> Bool {
|
||||
if openViaResponderChain(url, from: controller) {
|
||||
return true
|
||||
}
|
||||
return openViaSharedApplication(url)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaResponderChain(
|
||||
_ url: URL,
|
||||
from controller: KeyboardViewController
|
||||
) -> Bool {
|
||||
var responder: UIResponder? = controller
|
||||
while let current = responder {
|
||||
if let application = current as? UIApplication {
|
||||
application.open(url, options: [:]) { _ in }
|
||||
return true
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openViaSharedApplication(_ url: URL) -> Bool {
|
||||
guard
|
||||
let application = UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication
|
||||
else {
|
||||
return false
|
||||
}
|
||||
application.open(url, options: [:]) { _ in }
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,34 @@ public final class PermissionManager: @unchecked Sendable {
|
||||
/// method of its own and the framework checks the same TCC
|
||||
/// entry on first use.
|
||||
public func requestSpeechPermission() async -> Bool {
|
||||
await Self.requestSpeechPermissionNonisolated()
|
||||
}
|
||||
|
||||
// MARK: - Nonisolated permission bridge
|
||||
//
|
||||
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
|
||||
// to run on main queue. Building the callback inline inside a
|
||||
// `@MainActor` method can trigger runtime actor/isolation assertions.
|
||||
// Keep the continuation + callback creation in nonisolated helpers.
|
||||
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
|
||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
switch status {
|
||||
case .authorized: cont.resume(returning: true)
|
||||
case .denied, .restricted, .notDetermined:
|
||||
cont.resume(returning: false)
|
||||
@unknown default:
|
||||
cont.resume(returning: false)
|
||||
}
|
||||
SFSpeechRecognizer.requestAuthorization(
|
||||
makeSpeechAuthHandler(continuation: cont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func makeSpeechAuthHandler(
|
||||
continuation: CheckedContinuation<Bool, Never>
|
||||
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
|
||||
return { status in
|
||||
switch status {
|
||||
case .authorized:
|
||||
continuation.resume(returning: true)
|
||||
case .denied, .restricted, .notDetermined:
|
||||
continuation.resume(returning: false)
|
||||
@unknown default:
|
||||
continuation.resume(returning: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user