diff --git a/CHANGELOG.md b/CHANGELOG.md index 935a096..deb6ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed +- **Voluntary tip purchase**: remove the `ByRockyACoffee` consumable, the macOS Settings support-the-developer section, and the unused iOS tip view. Optional credit packs are now the only in-app purchase. / **自愿打赏**:移除 `ByRockyACoffee` 消耗型内购、macOS 设置中的支持开发者区域,以及未被使用的 iOS 打赏视图;可选积分包成为唯一的 App 内购买项。 + ### Added - **Flow startup diagnostics**: save a privacy-safe ten-second state trace whenever session startup reaches a terminal failure, with local export and deletion controls in Settings. / **Flow 启动诊断**:会话启动最终失败时保存隐私安全的最近十秒状态轨迹,并在设置中提供本机导出与清除入口。 @@ -18,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Personal style threshold**: allow generating a learned speaking style after 2,500 effective dictation characters instead of 5,000. / **专属风格门槛**:生成学习型说话风格所需的有效听写字符由 5,000 降至 2,500。 ### Fixed +- **Clipboard skill matching**: classify multi-sentence pastes sentence by sentence instead of as one block, keep a message's own skills available when a link or phone number is embedded in it, and scale the summary and list thresholds to Chinese text density. / **剪贴板技能匹配**:多句粘贴改为逐句识别而非整段判断,消息中内嵌链接或电话号码时保留消息本身的技能,并按中文文本密度调整总结与清单的长度阈值。 - **Durable Apple account sessions**: persist refresh operation identifiers before token rotation, reliably store and validate Apple user identifiers, require one-time reauthentication for unverifiable legacy sessions, and prevent failed Keychain deletion or concurrent account work from restoring a revoked login. / **可靠 Apple 账号会话**:在令牌轮换前持久化刷新操作标识,可靠存储并验证 Apple 用户标识,为无法验证的旧会话执行一次重新认证,并防止 Keychain 删除失败或并发账号操作恢复已撤销的登录态。 - **Account sign-in presentation**: use the concise reward message consistently and keep a uniform app surface beneath translucent Apple authorization sheets. / **账号登录呈现**:统一使用精简的积分奖励文案,并在半透明 Apple 授权弹窗下保持一致的 App 背景。 - **Recommended hotword details**: label Home recommendations by their actual source, render suggestion chips in adaptive monochrome, remove internal usage counts from dictionary rows, and anchor the iPhone confirmation popover to the selected hotword. / **推荐热词详情**:按真实来源标记首页推荐词,以自适应黑白配色显示推荐标签,移除词库列表中的内部使用次数,并让 iPhone 确认弹窗指向所选热词。 @@ -105,8 +109,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.8.0] - 2026-08-14 -> **Release highlights**: See the concise [1.8.0 release notes](docs/RELEASE_NOTES_1.8.0.md) for the key changes since 1.6.6. / **版本亮点**:请参阅精简的 [1.8.0 更新说明](docs/RELEASE_NOTES_1.8.0.md),了解自 1.6.6 以来的关键变化。 - ### Added - **English QuickType bar**: while typing a word, three equal slots show the verbatim text (quoted when unknown), the unique Space correction, and a completion. The bar stays empty before typing and between committed words. / **英文 QuickType 栏**:输入单词时,三个等宽格显示原文(生词带引号)、空格会采用的唯一纠错和补全;尚未输入及单词提交后保持空白。 - **System English lexicon**: typing uses `UITextChecker` completions/guesses and `requestSupplementaryLexicon` contact names / text replacements. / **系统英文词库**:打字使用 `UITextChecker` 补全/猜测,以及 `requestSupplementaryLexicon` 的通讯录名与文本替换。 diff --git a/OSGKeyboard.storekit b/OSGKeyboard.storekit index a0a7d8e..92ffe9b 100644 --- a/OSGKeyboard.storekit +++ b/OSGKeyboard.storekit @@ -14,26 +14,6 @@ ], "products" : [ - { - "displayPrice" : "28", - "familyShareable" : false, - "internalID" : "6789921326", - "localizations" : [ - { - "description" : "Support me keep vibe coding~ Voluntary tip — does not unlock any features.", - "displayName" : "Buy me a cup of coffee", - "locale" : "en_US" - }, - { - "description" : "支持一下老刘的 Vibe Coding 工作。自愿打赏,不解锁任何功能。", - "displayName" : "给老刘买杯冰美式", - "locale" : "zh_Hans" - } - ], - "productID" : "ByRockyACoffee", - "referenceName" : "给老刘买杯咖啡", - "type" : "Consumable" - }, { "displayPrice" : "8", "familyShareable" : false, diff --git a/OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift b/OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift new file mode 100644 index 0000000..5393607 --- /dev/null +++ b/OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift @@ -0,0 +1,80 @@ +// AccountAnalyticsBearerBridge.swift +// OSGKeyboard · Main App / Mac +// +// Bridges account credentials into the analytics uploader. Extracted from +// `AnalyticsHostService.swift` (UIKit-only) so the macOS target — which +// compiles the account stack at source level — can reuse it unchanged. + +#if canImport(OSGKeyboardHostSupport) +import OSGKeyboardHostSupport +#endif +#if canImport(OSGKeyboardShared) +import OSGKeyboardShared +#endif +import Foundation + +final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable { + static let shared = HostAnalyticsBearerBridge() + + private let lock = NSLock() + private var provider: (any AnalyticsBearerProviding)? + + func install(_ provider: any AnalyticsBearerProviding) { + lock.lock() + self.provider = provider + lock.unlock() + } + + func bearerToken() async throws -> String? { + try await currentProvider()?.bearerToken() + } + + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? { + try await currentProvider()?.refreshBearerToken( + afterUnauthorizedAccessToken: failedToken + ) + } + + private func currentProvider() -> (any AnalyticsBearerProviding)? { + lock.lock() + defer { lock.unlock() } + return provider + } +} + +struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding { + let apiClient: AccountAPIClient + + func bearerToken() async throws -> String? { + guard try await apiClient.currentSession() != nil else { return nil } + do { + return try await apiClient.accessTokenForAuthorizedRequest() + } catch let error as AccountAPIError where Self.isInvalidSession(error) { + return nil + } + } + + func refreshBearerToken( + afterUnauthorizedAccessToken failedToken: String? + ) async throws -> String? { + guard let failedToken, !failedToken.isEmpty else { return nil } + do { + return try await apiClient.refreshAccessToken( + afterUnauthorizedAccessToken: failedToken + ) + } catch let error as AccountAPIError where Self.isInvalidSession(error) { + return nil + } + } + + private static func isInvalidSession(_ error: AccountAPIError) -> Bool { + switch error { + case .sessionUnavailable, .unauthorized, .refreshTokenReuse: + return true + default: + return false + } + } +} diff --git a/OSGKeyboard/Services/AnalyticsHostService.swift b/OSGKeyboard/Services/AnalyticsHostService.swift index 5361067..83f6417 100644 --- a/OSGKeyboard/Services/AnalyticsHostService.swift +++ b/OSGKeyboard/Services/AnalyticsHostService.swift @@ -12,72 +12,6 @@ import OSGKeyboardShared import OSLog import UIKit -final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable { - static let shared = HostAnalyticsBearerBridge() - - private let lock = NSLock() - private var provider: (any AnalyticsBearerProviding)? - - func install(_ provider: any AnalyticsBearerProviding) { - lock.lock() - self.provider = provider - lock.unlock() - } - - func bearerToken() async throws -> String? { - try await currentProvider()?.bearerToken() - } - - func refreshBearerToken( - afterUnauthorizedAccessToken failedToken: String? - ) async throws -> String? { - try await currentProvider()?.refreshBearerToken( - afterUnauthorizedAccessToken: failedToken - ) - } - - private func currentProvider() -> (any AnalyticsBearerProviding)? { - lock.lock() - defer { lock.unlock() } - return provider - } -} - -struct AccountAnalyticsBearerProvider: AnalyticsBearerProviding { - let apiClient: AccountAPIClient - - func bearerToken() async throws -> String? { - guard try await apiClient.currentSession() != nil else { return nil } - do { - return try await apiClient.accessTokenForAuthorizedRequest() - } catch let error as AccountAPIError where Self.isInvalidSession(error) { - return nil - } - } - - func refreshBearerToken( - afterUnauthorizedAccessToken failedToken: String? - ) async throws -> String? { - guard let failedToken, !failedToken.isEmpty else { return nil } - do { - return try await apiClient.refreshAccessToken( - afterUnauthorizedAccessToken: failedToken - ) - } catch let error as AccountAPIError where Self.isInvalidSession(error) { - return nil - } - } - - private static func isInvalidSession(_ error: AccountAPIError) -> Bool { - switch error { - case .sessionUnavailable, .unauthorized, .refreshTokenReuse: - return true - default: - return false - } - } -} - struct HostAnalyticsLogger: AnalyticsLogging { private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios", diff --git a/OSGKeyboard/Views/AIKeyboardDemoView.swift b/OSGKeyboard/Views/AIKeyboardDemoView.swift index 20d333a..801087d 100644 --- a/OSGKeyboard/Views/AIKeyboardDemoView.swift +++ b/OSGKeyboard/Views/AIKeyboardDemoView.swift @@ -22,6 +22,9 @@ struct AIKeyboardDemoView: View { @State private var scene: Scene = .settings @State private var levelTick = 0.35 + /// Mirrors the chat host for `--preview-fullscreen` recordings. + @State private var hostDraft: String = "" + @State private var hostSent = false init() { AIKeyboardView.debugSkipsLongPressCoach = true @@ -29,10 +32,24 @@ struct AIKeyboardDemoView: View { var body: some View { ZStack { - OSGColor.demoBackground.ignoresSafeArea() + if FeaturePreviewFlags.isFullscreen { + Color(uiColor: .systemGroupedBackground).ignoresSafeArea() + } else { + OSGColor.demoBackground.ignoresSafeArea() + } // Both scenes sit on the bottom band so what's-new crop matches Ext chrome. VStack(spacing: 0) { - Spacer(minLength: 0) + if FeaturePreviewFlags.isFullscreen { + FeaturePreviewHostDocument( + kind: .messages, + title: language == .en ? "Messages" : "信息", + text: hostDraft, + incoming: incoming, + isSent: hostSent + ) + } else { + Spacer(minLength: 0) + } switch scene { case .settings: ThemedRoot { @@ -116,8 +133,10 @@ struct AIKeyboardDemoView: View { try? await sleep(3.0) state.aiSession.markAnswerInserted(offersSend: true) + hostDraft = answer try? await sleep(1.1) state.aiSession.markAnswerSent() + hostSent = true try? await sleep(1.6) } @@ -149,6 +168,13 @@ struct AIKeyboardDemoView: View { : "周末去哪儿玩比较合适?" } + /// The message being replied to — same seed the extension host uses. + private var incoming: String { + language == .en + ? "Looking for a place to relax this weekend" + : "周末想找个地方放松一下" + } + private var answer: String { language == .en ? "Try a nearby town day trip: walk through a park or old street, stop at a café, then have a local dinner." diff --git a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift index 0ae1187..65bb895 100644 --- a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift +++ b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift @@ -9,11 +9,6 @@ import OSGKeyboardHostSupport import OSGKeyboardShared import SwiftUI -enum AccountAppleAuthorizationPurpose { - case signIn - case deleteAccount -} - struct AccountAppleAuthorizationButton: View { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var coordinator: AccountSessionCoordinator @@ -140,50 +135,3 @@ struct AccountAppleAuthorizationButton: View { } } } - -private enum AccountAppleAuthorizationPayload { - static func make( - from authorization: ASAuthorization, - rawNonce: String - ) -> AppleAuthorizationPayload? { - guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let identityTokenData = credential.identityToken, - let authorizationCodeData = credential.authorizationCode, - let identityToken = String(data: identityTokenData, encoding: .utf8), - let authorizationCode = String(data: authorizationCodeData, encoding: .utf8), - !identityToken.isEmpty, - !authorizationCode.isEmpty, - !credential.user.isEmpty - else { - return nil - } - - return AppleAuthorizationPayload( - identityToken: identityToken, - authorizationCode: authorizationCode, - nonce: rawNonce, - displayName: displayName(from: credential.fullName), - userIdentifier: credential.user - ) - } - - private static func displayName(from components: PersonNameComponents?) -> String? { - guard let components else { return nil } - let value = PersonNameComponentsFormatter.localizedString( - from: components, - style: .default, - options: [] - ) - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} - -private enum AccountAppleNonce { - static func make() -> AppleSignInNonce? { - // Do not fall back to a merely unique identifier if the system RNG is - // unavailable. The completion path rejects authorization without a - // cryptographically random raw nonce. - return try? AppleSignInNonceGenerator().makeNonce() - } -} diff --git a/OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift b/OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift new file mode 100644 index 0000000..4e3c68d --- /dev/null +++ b/OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift @@ -0,0 +1,67 @@ +// AccountAppleAuthorizationFactories.swift +// OSGKeyboard · Main App / Mac +// +// Nonce generation and ASAuthorization → payload mapping, shared by the iOS +// account center and the macOS account page. Extracted from +// `AccountAppleAuthorizationButton.swift` (iOS-only view) so both platforms +// run the identical credential validation instead of a second copy. + +import AuthenticationServices +import Foundation +#if canImport(OSGKeyboardHostSupport) +import OSGKeyboardHostSupport +#endif + +/// Whether an Apple authorization pass is establishing a session or +/// re-authenticating for account deletion. +enum AccountAppleAuthorizationPurpose { + case signIn + case deleteAccount +} + +enum AccountAppleAuthorizationPayload { + static func make( + from authorization: ASAuthorization, + rawNonce: String + ) -> AppleAuthorizationPayload? { + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let identityTokenData = credential.identityToken, + let authorizationCodeData = credential.authorizationCode, + let identityToken = String(data: identityTokenData, encoding: .utf8), + let authorizationCode = String(data: authorizationCodeData, encoding: .utf8), + !identityToken.isEmpty, + !authorizationCode.isEmpty, + !credential.user.isEmpty + else { + return nil + } + + return AppleAuthorizationPayload( + identityToken: identityToken, + authorizationCode: authorizationCode, + nonce: rawNonce, + displayName: displayName(from: credential.fullName), + userIdentifier: credential.user + ) + } + + private static func displayName(from components: PersonNameComponents?) -> String? { + guard let components else { return nil } + let value = PersonNameComponentsFormatter.localizedString( + from: components, + style: .default, + options: [] + ) + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +enum AccountAppleNonce { + static func make() -> AppleSignInNonce? { + // Do not fall back to a merely unique identifier if the system RNG is + // unavailable. The completion path rejects authorization without a + // cryptographically random raw nonce. + return try? AppleSignInNonceGenerator().makeNonce() + } +} diff --git a/OSGKeyboard/Views/Account/AccountModels.swift b/OSGKeyboard/Views/Account/AccountCenterModels.swift similarity index 99% rename from OSGKeyboard/Views/Account/AccountModels.swift rename to OSGKeyboard/Views/Account/AccountCenterModels.swift index 62d7717..b7e8462 100644 --- a/OSGKeyboard/Views/Account/AccountModels.swift +++ b/OSGKeyboard/Views/Account/AccountCenterModels.swift @@ -1,4 +1,4 @@ -// AccountModels.swift +// AccountCenterModels.swift // OSGKeyboard · Main App // // Account-center domain models and narrow service boundaries. Transport DTOs diff --git a/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift b/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift index f37656f..699b2b4 100644 --- a/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift +++ b/OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift @@ -1,13 +1,17 @@ // AccountCreditPurchaseManager.swift // OSGKeyboard · Main App // -// StoreKit credit purchases are isolated from the voluntary tip product. +// StoreKit credit purchases for the optional managed account. // Transactions finish only after the account server verifies and records them. import Combine import Foundation +#if canImport(OSGKeyboardHostSupport) import OSGKeyboardHostSupport +#endif +#if canImport(OSGKeyboardShared) import OSGKeyboardShared +#endif struct AccountCreditPurchaseOption: Identifiable, Equatable { var id: String { productID } diff --git a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift index b78989c..406528c 100644 --- a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift +++ b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift @@ -7,7 +7,9 @@ import AuthenticationServices import Combine import Foundation +#if canImport(OSGKeyboardShared) import OSGKeyboardShared +#endif @MainActor final class AccountSessionCoordinator: ObservableObject { @@ -37,6 +39,10 @@ final class AccountSessionCoordinator: ObservableObject { @Published private(set) var snapshotPhase: SnapshotPhase = .idle @Published private(set) var operation: Operation? @Published private(set) var operationErrorKey: String? + /// Machine-readable code for `operationErrorKey` (see `AccountDiagnostic`). + /// The localized key alone says "sign-in failed"; this says *why*, which is + /// the difference between a bug report we can act on and one we cannot. + @Published private(set) var operationErrorDetail: String? @Published private(set) var pendingReferralCode: String? @Published private(set) var shouldPresentAccountCenter = false @Published private(set) var lastAccountRefreshAt: Date? @@ -48,6 +54,11 @@ final class AccountSessionCoordinator: ObservableObject { private let sessionService: any AccountSessionServicing private let centerService: any AccountCenterServicing + /// False on macOS: a Developer ID build cannot use StoreKit, so starting a + /// purchase session there would fetch a product catalog and open a + /// `Transaction` listener for a store that can never complete a purchase. + /// Credits are still spent normally — only buying is unavailable. + private let arePurchasesAvailable: Bool private let pendingReferralStore: any PendingReferralCodeStoring private let analyticsClient: any AnalyticsClient private let onAccountAuthenticated: (UUID) async -> Void @@ -71,6 +82,7 @@ final class AccountSessionCoordinator: ObservableObject { referralProfileStore: any ReferralProfileStoring = UserDefaultsReferralProfileStore(), accountRefreshInterval: TimeInterval = 10 * 60, + arePurchasesAvailable: Bool = true, now: @escaping () -> Date = Date.init, notificationCenter: NotificationCenter = .default, analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), @@ -95,6 +107,7 @@ final class AccountSessionCoordinator: ObservableObject { self.onAccountSignedOut = onAccountSignedOut self.onAccountDeleted = onAccountDeleted self.accountRefreshInterval = accountRefreshInterval + self.arePurchasesAvailable = arePurchasesAvailable self.now = now pendingReferralCode = pendingReferralStore.code let sessionEventSource = dependencies.sessionEventSource @@ -138,6 +151,7 @@ final class AccountSessionCoordinator: ObservableObject { didAttemptRestore = true sessionPhase = .restoring operationErrorKey = nil + operationErrorDetail = nil do { guard let session = try await sessionService.restoreSession() else { @@ -149,7 +163,9 @@ final class AccountSessionCoordinator: ObservableObject { await onAccountAuthenticated(session.accountID) advanceSessionRevision() sessionPhase = .signedIn(session) - creditPurchases.startSession(accountID: session.accountID) + if arePurchasesAvailable { + creditPurchases.startSession(accountID: session.accountID) + } referralProfile.startSession(accountID: session.accountID) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) @@ -196,6 +212,7 @@ final class AccountSessionCoordinator: ObservableObject { pendingReferralCode = code shouldPresentAccountCenter = true operationErrorKey = nil + operationErrorDetail = nil analyticsClient.recordInviteOpened() if isSignedIn { @@ -217,6 +234,7 @@ final class AccountSessionCoordinator: ObservableObject { guard operation == nil else { return } operation = .signingIn operationErrorKey = nil + operationErrorDetail = nil defer { operation = nil } do { @@ -224,7 +242,9 @@ final class AccountSessionCoordinator: ObservableObject { await onAccountAuthenticated(session.accountID) advanceSessionRevision() sessionPhase = .signedIn(session) - creditPurchases.startSession(accountID: session.accountID) + if arePurchasesAvailable { + creditPurchases.startSession(accountID: session.accountID) + } referralProfile.startSession(accountID: session.accountID) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) @@ -233,6 +253,7 @@ final class AccountSessionCoordinator: ObservableObject { for: error, fallback: "account.error.signIn" ) + operationErrorDetail = AccountDiagnostic.code(for: error) } } @@ -287,6 +308,7 @@ final class AccountSessionCoordinator: ObservableObject { let expectedRevision = sessionRevision operation = .updatingProfile operationErrorKey = nil + operationErrorDetail = nil defer { operation = nil } do { @@ -325,6 +347,7 @@ final class AccountSessionCoordinator: ObservableObject { let expectedRevision = sessionRevision operation = .preparingManagedGateway operationErrorKey = nil + operationErrorDetail = nil defer { operation = nil } do { try await sessionService.prepareManagedGateway() @@ -355,6 +378,7 @@ final class AccountSessionCoordinator: ObservableObject { guard operation == nil, isSignedIn else { return } operation = .signingOut operationErrorKey = nil + operationErrorDetail = nil defer { operation = nil } referralProfile.cancelRefresh() @@ -379,6 +403,7 @@ final class AccountSessionCoordinator: ObservableObject { guard operation == nil, isSignedIn else { return } operation = .deletingAccount operationErrorKey = nil + operationErrorDetail = nil defer { operation = nil } referralProfile.cancelRefresh() @@ -402,12 +427,14 @@ final class AccountSessionCoordinator: ObservableObject { } } - func recordAppleAuthorizationFailure() { + func recordAppleAuthorizationFailure(detail: String = "apple-authorization") { operationErrorKey = "account.error.appleAuthorization" + operationErrorDetail = detail } func dismissOperationError() { operationErrorKey = nil + operationErrorDetail = nil } private func performAccountRefresh(for accountID: UUID, requestID: UUID) async { diff --git a/OSGKeyboard/Views/Account/LiveAccountServices.swift b/OSGKeyboard/Views/Account/LiveAccountServices.swift index 37a88b2..149e282 100644 --- a/OSGKeyboard/Views/Account/LiveAccountServices.swift +++ b/OSGKeyboard/Views/Account/LiveAccountServices.swift @@ -7,8 +7,12 @@ import AuthenticationServices import Foundation +#if canImport(OSGKeyboardHostSupport) import OSGKeyboardHostSupport +#endif +#if canImport(OSGKeyboardShared) import OSGKeyboardShared +#endif @MainActor enum LiveAccountDependencyFactory { @@ -333,7 +337,7 @@ private actor LiveAccountService: ) throw AccountAPIError.secureStorage } - let session: OSGKeyboardHostSupport.AccountSession + let session: AccountTokenSession do { session = try await apiClient.signInWithApple( AppleSignInRequest( @@ -603,7 +607,7 @@ private actor LiveAccountService: } private func uiSession( - _ session: OSGKeyboardHostSupport.AccountSession, + _ session: AccountTokenSession, createdAtEpochSeconds: Int64, displayName: String? ) -> AccountSession { @@ -616,7 +620,11 @@ private actor LiveAccountService: } -private enum AccountDiagnostic { +/// Stable, non-identifying failure codes for account traffic. Logged on both +/// platforms and — on macOS, where sign-in has no App Store receipt trail to +/// fall back on — also shown under the error alert so a failure can be +/// reported without a console capture. +enum AccountDiagnostic { static func code(for error: Error) -> String { guard let error = error as? AccountAPIError else { return String(describing: type(of: error)) diff --git a/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift b/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift index 2f3920c..21cd05d 100644 --- a/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift +++ b/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift @@ -5,7 +5,9 @@ // the local cache keeps the permanent invitation link visible when offline. import Foundation +#if canImport(OSGKeyboardHostSupport) import OSGKeyboardHostSupport +#endif @MainActor final class ReferralProfileViewModel: ObservableObject { diff --git a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift index 4b6e567..dd60797 100644 --- a/OSGKeyboard/Views/ClipboardHistoryDemoView.swift +++ b/OSGKeyboard/Views/ClipboardHistoryDemoView.swift @@ -22,11 +22,18 @@ struct ClipboardHistoryDemoView: View { @StateObject private var state = KeyboardState() @StateObject private var typing = TypingSessionController() - @StateObject private var history = ClipboardHistoryStore( - defaults: UserDefaults(suiteName: "osg.whatsnew.clipboard.demo") - ) + // `AIKeyboardView` ranks its skill row off `ClipboardHistoryStore.shared` + // and `ClipboardSemanticRankingStore.shared`. Driving a private store here + // would leave the row to be faked, which is exactly what made earlier takes + // diverge from the shipping UI. + @ObservedObject private var history = ClipboardHistoryStore.shared + @ObservedObject private var ranking = ClipboardSemanticRankingStore.shared @Environment(\.colorScheme) private var colorScheme + /// Mirrors the chat host for `--preview-fullscreen` recordings. + @State private var hostDraft: String = "" + /// Closing beat: hand the copied text to the real AI skill row. + @State private var showsSkills = false private var palette: ThemePalette { colorScheme == .dark ? Palette.dark : Palette.light @@ -34,9 +41,22 @@ struct ClipboardHistoryDemoView: View { var body: some View { ZStack { - OSGColor.demoBackground.ignoresSafeArea() + if FeaturePreviewFlags.isFullscreen { + Color(uiColor: .systemGroupedBackground).ignoresSafeArea() + } else { + OSGColor.demoBackground.ignoresSafeArea() + } VStack(spacing: 0) { - Spacer(minLength: 0) + if FeaturePreviewFlags.isFullscreen { + FeaturePreviewHostDocument( + kind: .messages, + title: "信息", + text: hostDraft, + incoming: Self.incomingMessage + ) + } else { + Spacer(minLength: 0) + } keyboardChrome .background(palette.background.ignoresSafeArea(edges: .bottom)) .overlay(alignment: .top) { @@ -56,9 +76,15 @@ struct ClipboardHistoryDemoView: View { private var keyboardChrome: some View { ZStack { - voiceSurface - .opacity(state.clipboardOverlay == .none ? 1 : 0) - .allowsHitTesting(state.clipboardOverlay == .none) + if showsSkills { + // Real unified AI surface — the skill row owns「一键回复」. + AIKeyboardView(state: state, typing: typing, onInsert: { _ in }) + .transition(.opacity) + } else { + voiceSurface + .opacity(state.clipboardOverlay == .none ? 1 : 0) + .allowsHitTesting(state.clipboardOverlay == .none) + } if state.clipboardOverlay == .historyPanel { ClipboardHistoryPanelView( @@ -228,6 +254,134 @@ struct ClipboardHistoryDemoView: View { } } try? await sleep(2.4) + + guard FeaturePreviewFlags.isFullscreen else { return } + await runReplyBeat() + } + + /// Reply styles are separate skills in the row rather than variants offered + /// after one tap, so the beat runs the same copied message through two of + /// them back to back — that is what "多个风格的回复" looks like in the real UI. + /// + /// Which chips appear is decided by `ClipboardSkillSemanticRanker`, not by + /// this file: production caps specialised reply skills at two alongside the + /// generic 回复, so hand-picking a longer row produced a keyboard that could + /// never exist. The beat therefore taps whatever the ranker surfaced. + private func runReplyBeat() async { + AIKeyboardView.debugSkipsLongPressCoach = true + AIKeyboardView.debugPreviewSkills = nil + state.aiServiceAvailable = true + state.surface = .ai + state.aiSession.enter() + state.pendingClipboardSkillID = nil + withAnimation(.easeInOut(duration: 0.3)) { + showsSkills = true + } + + let skills = await rankedReplySkills() + try? await sleep(0.85) + + guard let generic = skills.first(where: { + $0.id == AIClipboardSkillCatalog.replyID + }) else { return } + await runReplySkill( + skill: generic, + answer: "好的,预算版我整理一下,明天上午发你。" + ) + state.pendingClipboardSkillID = nil + try? await sleep(0.6) + + // Second style: whatever specialised reply the ranker actually offered. + if let specialised = skills.first(where: { + $0.supportsReplyStyle && $0.id != AIClipboardSkillCatalog.replyID + }) { + await runReplySkill( + skill: specialised, + answer: Self.answer(for: specialised.id) + ) + } + // End back on the skill row so the card loops without a jump. + state.pendingClipboardSkillID = nil + try? await sleep(0.25) + } + + /// Runs the real semantic analysis the keyboard uses, then reports the row + /// it produced so the beat can tap chips that are genuinely on screen. + private func rankedReplySkills() async -> [AIClipboardSkill] { + guard let newest = history.newestEntry else { return [] } + ranking.analyze(newest) + // The analyzer runs off the main actor; wait for its snapshot rather + // than racing it, or the row falls back to a lone 回复 chip. + for _ in 0..<40 { + if ranking.snapshot?.entryID == newest.id { break } + try? await Task.sleep(nanoseconds: 50_000_000) + } + guard let snapshot = ranking.snapshot, snapshot.entryID == newest.id else { + return state.clipboardSkillCatalog.filter { + $0.id == AIClipboardSkillCatalog.replyID + } + } + return ClipboardSkillSemanticRanker.recommended( + skills: state.clipboardSkillCatalog, + sourceText: newest.text, + analysis: snapshot.analysis, + uiLanguage: state.uiLanguage, + limit: 5 + ) + } + + /// Demo copy per reply style. Keyed by skill so the answer matches whatever + /// chip the ranker put in the row. + private static func answer(for skillID: String) -> String { + switch skillID { + case AIClipboardSkillCatalog.playfulReplyID: + return "行嘞,预算表这就去梳妆打扮,明早准时上桌。" + case AIClipboardSkillCatalog.businessReplyID: + return "收到,预算版本我今晚整理完,明日上午发送给您。" + case AIClipboardSkillCatalog.empathyReplyID: + return "理解,这版确实等着用。我加紧整理,明早一定发你。" + case AIClipboardSkillCatalog.acceptTaskID: + return "好的,这件事我接了,明天上午把预算版发你。" + case AIClipboardSkillCatalog.clarifyRequestID: + return "没问题,你要的是含人力成本的那版,还是只要采购部分?" + default: + return "好的,我整理一下,明天上午发你。" + } + } + + /// One tap on a reply-style chip: the row gives way to the thinking state, + /// the answer streams in, then it is offered for insertion. + private func runReplySkill(skill: AIClipboardSkill, answer: String) async { + // Drives the real generating copy (falls through to「AI 正在思考…」, + // same as a production skill run). + state.pendingClipboardSkillID = skill.id + + // `beginGenerating` (and every later step) is guarded on + // `activeUtteranceID`, which only `beginPreparing` sets — skipping it + // makes the whole sequence a silent no-op and the keyboard never + // shows an answer. + let utteranceID = UUID() + state.aiSession.enter() + state.aiSession.beginPreparing(utteranceID: utteranceID) + state.aiSession.beginGenerating(question: "", utteranceID: utteranceID) + try? await sleep(0.2) + + let chars = Array(answer) + var index = 0 + while index < chars.count { + index = min(chars.count, index + 3) + state.aiSession.receivePartialAnswer( + String(chars[.. some View { + HStack { + if outgoing { Spacer(minLength: 40) } + Text(content) + .font(.system(size: 17)) + .foregroundStyle(outgoing ? Color.white : Color(uiColor: .label)) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill( + outgoing + ? Color(red: 0.20, green: 0.66, blue: 0.38) + : Color(uiColor: .tertiarySystemFill) + ) + ) + if !outgoing { Spacer(minLength: 40) } + } + } + + private var caret: some View { + RoundedRectangle(cornerRadius: 1) + .fill(Color.accentColor) + .frame(width: 2, height: 24) + .padding(.leading, 1) + } + + private var placeholder: String { + switch kind { + case .notes: return "开始记录…" + case .messages: return "" + } + } +} +#endif diff --git a/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift b/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift index 8723b26..cf0c3e1 100644 --- a/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift +++ b/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift @@ -75,7 +75,30 @@ public struct MonthlyUsageCalendar: View { } private var cellDiameter: CGFloat { + #if os(macOS) + // Mac 端收紧圆点直径,让左侧日历与右侧 2×2 网格高度接近。 + compact ? 26 : 28 + #else compact ? 28 : 32 + #endif + } + + /// 日期网格的行间距(Mac 端更紧凑)。 + private var dayRowSpacing: CGFloat { + #if os(macOS) + compact ? 2 : 3 + #else + compact ? 3 : 5 + #endif + } + + /// 卡片内各区块(标题 / 星期 / 日期网格)的垂直间距。 + private var sectionSpacing: CGFloat { + #if os(macOS) + Spacing.xs + #else + Spacing.sm + #endif } public var body: some View { @@ -91,7 +114,7 @@ public struct MonthlyUsageCalendar: View { } private var calendarContent: some View { - VStack(alignment: .leading, spacing: Spacing.sm) { + VStack(alignment: .leading, spacing: sectionSpacing) { header weekdayHeader dayGrid @@ -133,7 +156,7 @@ public struct MonthlyUsageCalendar: View { } private var dayGrid: some View { - LazyVGrid(columns: columns, spacing: compact ? 3 : 5) { + LazyVGrid(columns: columns, spacing: dayRowSpacing) { ForEach(Array(calendarCells.enumerated()), id: \.offset) { _, point in if let point { dayCell(point) @@ -161,7 +184,7 @@ public struct MonthlyUsageCalendar: View { let usesContrastingText = opacity >= 0.48 return Text(day.formatted()) - .font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded)) + .font(.system(size: dayFontSize, weight: isToday ? .semibold : .medium, design: .rounded)) .foregroundStyle( isFuture ? palette.textTertiary.opacity(0.45) @@ -185,6 +208,14 @@ public struct MonthlyUsageCalendar: View { ) } + private var dayFontSize: CGFloat { + #if os(macOS) + compact ? 11 : 12 + #else + compact ? 12 : 13 + #endif + } + private func fillOpacity(for value: Int, isFuture: Bool) -> Double { guard value > 0, !isFuture else { return 0 } let ratio = min(max(Double(value) / Double(maximumValue), 0), 1) diff --git a/OSGKeyboardHostSupport/DesignSystem/SupportDeveloperSection.swift b/OSGKeyboardHostSupport/DesignSystem/SupportDeveloperSection.swift deleted file mode 100644 index 0d58e1a..0000000 --- a/OSGKeyboardHostSupport/DesignSystem/SupportDeveloperSection.swift +++ /dev/null @@ -1,146 +0,0 @@ -// SupportDeveloperSection.swift -// OSGKeyboard · HostSupport -// -// Optional voluntary tip block for Settings. Does not gate features. - -import SwiftUI -#if canImport(OSGKeyboardShared) -import OSGKeyboardShared -#endif - -/// iOS Settings card for the consumable support tip. -public struct SupportDeveloperSection: View { - @ObservedObject private var tipManager: TipPurchaseManager - @Environment(\.themePalette) private var palette - - private let language: AppUILanguage - - @State private var showThankYouAlert = false - @State private var showErrorAlert = false - @State private var errorMessage = "" - - public init( - language: AppUILanguage, - tipManager: TipPurchaseManager = .shared - ) { - self.language = language - self.tipManager = tipManager - } - - public var body: some View { - CardSection(title: SharedL10n.string("tip.title", language: language)) { - VStack(alignment: .leading, spacing: Spacing.sm) { - SupportDeveloperTipBody(language: language) - - if tipManager.supportCount > 0 { - Text( - SharedL10n.format( - "tip.thankYou.past", - language: language, - tipManager.supportCount - ) - ) - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - } - - tipButton - .disabled(isPurchaseInFlight) - - Text(SharedL10n.string("tip.consumableNotice", language: language)) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .surfaceCard() - } - .onChange(of: tipManager.purchaseState) { _, newValue in - switch newValue { - case .succeeded: - showThankYouAlert = true - case .failed(let message): - errorMessage = message - showErrorAlert = true - default: - break - } - } - .alert( - SharedL10n.string("tip.thankYou.title", language: language), - isPresented: $showThankYouAlert - ) { - Button(SharedL10n.string("tip.alert.dismiss", language: language)) { - tipManager.acknowledgePurchaseState() - } - } message: { - Text(SharedL10n.string("tip.thankYou.message", language: language)) - } - .alert( - SharedL10n.string("tip.error.title", language: language), - isPresented: $showErrorAlert - ) { - Button(SharedL10n.string("tip.alert.dismiss", language: language)) { - tipManager.acknowledgePurchaseState() - } - } message: { - Text(errorMessage) - } - } - - private var isPurchaseInFlight: Bool { - switch tipManager.purchaseState { - case .loading, .purchasing: - return true - default: - return false - } - } - - private var tipButton: some View { - Button { - Task { await tipManager.purchase() } - } label: { - HStack(spacing: Spacing.sm) { - if isPurchaseInFlight { - ProgressView() - .controlSize(.small) - } - Text(tipButtonTitle) - .font(TypeStyle.body.weight(.semibold)) - } - .frame(maxWidth: .infinity) - .padding(.vertical, Spacing.sm) - .foregroundStyle(OSGColor.fixedLightContent) - .background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - } - .buttonStyle(.plain) - .accessibilityLabel(tipButtonTitle) - } - - private var tipButtonTitle: String { - if let product = tipManager.product { - return SharedL10n.format("tip.button", language: language, product.displayPrice) - } - return SharedL10n.string("tip.buttonFallback", language: language) - } -} - -/// Shared copy + tip button for macOS Settings (Mac chrome wraps this). -public struct SupportDeveloperTipBody: View { - @Environment(\.themePalette) private var palette - - private let language: AppUILanguage - - public init(language: AppUILanguage) { - self.language = language - } - - public var body: some View { - Text(SharedL10n.string("tip.body", language: language)) - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } -} diff --git a/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift b/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift index f148427..89028dd 100644 --- a/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift +++ b/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift @@ -98,7 +98,7 @@ public struct UsageStatsCluster: View { chartCard .frame(maxWidth: .infinity, maxHeight: .infinity) splitStatGrid - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .fixedSize(horizontal: false, vertical: true) } @@ -121,6 +121,7 @@ public struct UsageStatsCluster: View { systemImage: "waveform" ) } + .frame(maxHeight: splitRowMaxHeight) HStack(spacing: splitItemSpacing) { splitCell( title: SharedL10n.string("stat.translation", language: language), @@ -136,6 +137,7 @@ public struct UsageStatsCluster: View { action: onOpenDictionary ) } + .frame(maxHeight: splitRowMaxHeight) } } @@ -164,7 +166,8 @@ public struct UsageStatsCluster: View { title: title, value: value, caption: caption, - accent: accent + accent: accent, + expands: splitCellsFillHeight ) .overlay(alignment: .topTrailing) { disclosureIndicator @@ -173,15 +176,17 @@ public struct UsageStatsCluster: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: splitRowMaxHeight) } else { UsageStatCard( title: title, value: value, caption: caption, systemImage: systemImage, - accent: accent + accent: accent, + expands: splitCellsFillHeight ) + .frame(maxHeight: splitRowMaxHeight) } } @@ -335,7 +340,7 @@ public struct UsageStatsCluster: View { private var splitSectionSpacing: CGFloat { #if os(macOS) - Spacing.md + Spacing.sm #else CardLayoutMetrics.sectionSpacing #endif @@ -343,11 +348,24 @@ public struct UsageStatsCluster: View { private var splitItemSpacing: CGFloat { #if os(macOS) - Spacing.md + Spacing.sm #else CardLayoutMetrics.compactItemSpacing #endif } + + private var splitRowMaxHeight: CGFloat? { + splitCellsFillHeight ? .infinity : nil + } + + /// Mac 侧的 2×2 网格拉伸到与左侧日历同高,保证左右卡片底边对齐。 + private var splitCellsFillHeight: Bool { + #if os(macOS) + layout == .split + #else + false + #endif + } } extension UsageStatsCluster where Header == EmptyView { diff --git a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift index 9f564a8..c6350d3 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift @@ -80,7 +80,7 @@ public actor AccountAPIClient { private struct RefreshOperation { let id: UUID let failedAccessToken: String - let task: Task + let task: Task } private let baseURL: URL @@ -90,7 +90,7 @@ public actor AccountAPIClient { private let encoder: JSONEncoder private let decoder: JSONDecoder - private var cachedSession: AccountSession? + private var cachedSession: AccountTokenSession? private var didLoadSession = false private var refreshOperation: RefreshOperation? private var invalidationContinuations: [ @@ -113,19 +113,19 @@ public actor AccountAPIClient { } @discardableResult - public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountSession { + public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountTokenSession { let data = try await perform( endpoint: .appleSignIn, body: try encode(request), requiresSession: false ) - let session = try decode(APIDataEnvelope.self, from: data).data + let session = try decode(APIDataEnvelope.self, from: data).data try await replaceSession(with: session) try? await sessionVault.clearRefreshTransaction() return session } - public func currentSession() async throws -> AccountSession? { + public func currentSession() async throws -> AccountTokenSession? { try await loadSessionIfNeeded() } @@ -386,7 +386,7 @@ public actor AccountAPIClient { } } - private func sessionForRequest() async throws -> AccountSession { + private func sessionForRequest() async throws -> AccountTokenSession { guard let session = try await loadSessionIfNeeded() else { throw AccountAPIError.sessionUnavailable } @@ -403,7 +403,7 @@ public actor AccountAPIClient { return session } - private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession { + private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountTokenSession { // Refresh always re-reads Keychain so another client instance cannot // rotate a stale in-memory session with a new operation identifier. guard let current = try await reloadSessionFromVault() else { @@ -438,7 +438,7 @@ public actor AccountAPIClient { return try await finishRefresh(operation) } - private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession { + private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountTokenSession { do { let replacement = try await operation.task.value guard let current = try await reloadSessionFromVault() else { @@ -478,7 +478,7 @@ public actor AccountAPIClient { private func requestRefresh( using refreshToken: String, operationId: UUID - ) async throws -> AccountSession { + ) async throws -> AccountTokenSession { let request = try makeRequest( endpoint: .refresh, body: try encode( @@ -491,7 +491,7 @@ public actor AccountAPIClient { ) let response = try await send(request) let data = try validatedData(response) - return try decode(APIDataEnvelope.self, from: data).data + return try decode(APIDataEnvelope.self, from: data).data } private func shouldClearSession(afterRefreshError error: Error) -> Bool { @@ -504,7 +504,7 @@ public actor AccountAPIClient { } } - private func loadSessionIfNeeded() async throws -> AccountSession? { + private func loadSessionIfNeeded() async throws -> AccountTokenSession? { if didLoadSession { return cachedSession } @@ -517,7 +517,7 @@ public actor AccountAPIClient { } } - private func reloadSessionFromVault() async throws -> AccountSession? { + private func reloadSessionFromVault() async throws -> AccountTokenSession? { do { let session = try await sessionVault.loadSession() cachedSession = session @@ -528,7 +528,7 @@ public actor AccountAPIClient { } } - private func replaceSession(with session: AccountSession) async throws { + private func replaceSession(with session: AccountTokenSession) async throws { do { try await sessionVault.saveSession(session) cachedSession = session diff --git a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift index 7395c6f..9ee31f0 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift @@ -5,7 +5,7 @@ import Foundation -public struct AccountSession: Codable, Equatable, Sendable { +public struct AccountTokenSession: Codable, Equatable, Sendable { public let accountId: UUID public let tokenType: String public let accessToken: String @@ -229,8 +229,8 @@ public struct AccountRefreshTransaction: Codable, Equatable, Sendable { } public protocol AccountSessionVault: Sendable { - func loadSession() async throws -> AccountSession? - func saveSession(_ session: AccountSession) async throws + func loadSession() async throws -> AccountTokenSession? + func saveSession(_ session: AccountTokenSession) async throws func clearSession() async throws func beginRefreshTransaction( refreshTokenDigest: String diff --git a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift index a5d323c..021a1e1 100644 --- a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift +++ b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift @@ -10,6 +10,10 @@ import Security public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable { public static let defaultService = "com.osgkeyboard.ios.account" public static let hostBundleIdentifier = "com.osgkeyboard.ios" + /// The macOS menu-bar app has no extension to share with, so it owns its + /// own private group. Listed here so the guard below still rejects the + /// App-Group-reachable `com.osgkeyboard.shared` group on both platforms. + public static let macHostBundleIdentifier = "com.osgkeyboard.mac" public let service: String public let accessGroup: String @@ -20,8 +24,10 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable { ) throws { let normalizedService = service.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedAccessGroup = accessGroup.trimmingCharacters(in: .whitespacesAndNewlines) + let isHostPrivate = [Self.hostBundleIdentifier, Self.macHostBundleIdentifier] + .contains { normalizedAccessGroup.hasSuffix(".\($0)") } guard !normalizedService.isEmpty, - normalizedAccessGroup.hasSuffix(".\(Self.hostBundleIdentifier)"), + isHostPrivate, !normalizedAccessGroup.hasSuffix(".com.osgkeyboard.shared") else { throw AccountAPIError.secureStorage } @@ -67,9 +73,9 @@ public actor HostPrivateAccountKeychain: self.decoder = JSONDecoder() } - public func loadSession() async throws -> AccountSession? { + public func loadSession() async throws -> AccountTokenSession? { do { - let session = try read(AccountSession.self, account: Account.session) + let session = try read(AccountTokenSession.self, account: Account.session) Self.logger.info( "session keychain restore status=\(session == nil ? "not-found" : "found", privacy: .public)" ) @@ -80,7 +86,7 @@ public actor HostPrivateAccountKeychain: } } - public func saveSession(_ session: AccountSession) async throws { + public func saveSession(_ session: AccountTokenSession) async throws { try write(session, account: Account.session) } @@ -205,12 +211,20 @@ public actor HostPrivateAccountKeychain: } private func baseQuery(account: String) -> [String: Any] { - [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: descriptor.service, kSecAttrAccount as String: account, kSecAttrAccessGroup as String: descriptor.accessGroup, kSecAttrSynchronizable as String: kCFBooleanFalse! ] + #if os(macOS) + // macOS defaults to the legacy file-based keychain, which ignores + // `kSecAttrAccessGroup` and rejects `kSecAttrAccessible`. Opt into the + // data-protection keychain so entitlement-scoped access groups and the + // AfterFirstUnlockThisDeviceOnly protection behave as they do on iOS. + query[kSecUseDataProtectionKeychain as String] = true + #endif + return query } } diff --git a/OSGKeyboardHostSupport/Features/Account/OOBEGrantProvisioningCoordinator.swift b/OSGKeyboardHostSupport/Features/Account/OOBEGrantProvisioningCoordinator.swift index 8296add..1976e74 100644 --- a/OSGKeyboardHostSupport/Features/Account/OOBEGrantProvisioningCoordinator.swift +++ b/OSGKeyboardHostSupport/Features/Account/OOBEGrantProvisioningCoordinator.swift @@ -2,7 +2,7 @@ // OSGKeyboard · HostSupport // // Provisions anonymous, App-Attest-bound OOBE credentials without creating or -// mutating an AccountSession. +// mutating an AccountTokenSession. import Foundation #if canImport(OSGKeyboardShared) diff --git a/OSGKeyboardHostSupport/Features/Account/SignInWithApple.swift b/OSGKeyboardHostSupport/Features/Account/SignInWithApple.swift index f26122c..2943fdf 100644 --- a/OSGKeyboardHostSupport/Features/Account/SignInWithApple.swift +++ b/OSGKeyboardHostSupport/Features/Account/SignInWithApple.swift @@ -150,7 +150,7 @@ public actor AccountSignInCoordinator { } @discardableResult - public func signIn() async throws -> AccountSession { + public func signIn() async throws -> AccountTokenSession { let nonce = try nonceGenerator.makeNonce() let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex) let evidence = try await integrity.evidenceForAppleSignIn( diff --git a/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift b/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift index a7fd039..09f0b21 100644 --- a/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift +++ b/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift @@ -98,7 +98,11 @@ private final class FlowPrerollStore: @unchecked Sendable { } /// Rolling bar levels updated from the audio tap; read on the main actor. -private final class FlowLevelStore: @unchecked Sendable { +/// +/// Deliberately not `private`: the bar arithmetic runs on the realtime audio +/// thread where a bad range is an uncatchable trap, so it needs direct +/// `@testable` coverage rather than only being reachable through a live engine. +final class FlowLevelStore: @unchecked Sendable { private let lock = OSAllocatedUnfairLock() private var levels: [Float] @@ -115,7 +119,7 @@ private final class FlowLevelStore: @unchecked Sendable { lock.withLock { levels } } - private static func calculateLevels(from buffer: AVAudioPCMBuffer, barCount: Int) -> [Float] { + static func calculateLevels(from buffer: AVAudioPCMBuffer, barCount: Int) -> [Float] { guard let channelData = buffer.floatChannelData else { return Array(repeating: 0, count: barCount) } @@ -127,7 +131,12 @@ private final class FlowLevelStore: @unchecked Sendable { var result = [Float]() result.reserveCapacity(barCount) for barIndex in 0..? - - public init(defaults: UserDefaults = .standard) { - supportCount = defaults.integer(forKey: TipProduct.supportCountDefaultsKey) - transactionUpdatesTask = Task { [weak self] in - await self?.listenForTransactionUpdates() - } - Task { await loadProducts() } - } - - deinit { - transactionUpdatesTask?.cancel() - } - - /// Loads the tip product from the App Store / StoreKit Test configuration. - public func loadProducts() async { - guard purchaseState != .purchasing else { return } - purchaseState = .loading - do { - let products = try await Product.products(for: TipProduct.allProductIDs) - product = products.first(where: { $0.id == TipProduct.supportID }) - purchaseState = product == nil - ? .failed(SharedL10n.string("tip.error.productUnavailable")) - : .idle - } catch { - product = nil - purchaseState = .failed(error.localizedDescription) - } - } - - /// Starts the StoreKit purchase sheet for the support tip. - public func purchase() async { - guard let product else { - purchaseState = .failed(SharedL10n.string("tip.error.productUnavailable")) - return - } - purchaseState = .purchasing - do { - let result = try await product.purchase() - switch result { - case .success(let verification): - let transaction = try Self.verified(verification) - await handleCompletedTip(transaction) - purchaseState = .succeeded - case .userCancelled: - purchaseState = .idle - case .pending: - purchaseState = .failed(SharedL10n.string("tip.error.pending")) - @unknown default: - purchaseState = .failed(SharedL10n.string("tip.error.unknown")) - } - } catch { - purchaseState = .failed(error.localizedDescription) - } - } - - /// Clears transient success / failure state after the UI acknowledges it. - public func acknowledgePurchaseState() { - switch purchaseState { - case .succeeded, .failed: - purchaseState = .idle - default: - break - } - } - - private func listenForTransactionUpdates() async { - for await update in Transaction.updates { - guard let transaction = try? Self.verified(update), - transaction.productID == TipProduct.supportID else { continue } - await handleCompletedTip(transaction) - } - } - - private func handleCompletedTip(_ transaction: Transaction) async { - await transaction.finish() - recordSupportPurchase() - } - - private func recordSupportPurchase() { - supportCount += 1 - UserDefaults.standard.set(supportCount, forKey: TipProduct.supportCountDefaultsKey) - } - - private static func verified(_ result: VerificationResult) throws -> T { - switch result { - case .verified(let value): - return value - case .unverified: - throw TipPurchaseError.failedVerification - } - } -} - -private enum TipPurchaseError: Error { - case failedVerification -} diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index f79d6dd..e647b7c 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -172,7 +172,8 @@ struct BottomDictationBar: View { recordControl } .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) + .padding(.top, Spacing.xs) + .padding(.bottom, Spacing.sm) // No surface fill — the mic bar sits on the page background so Home // stays flat and the canvas above can stay shorter without a second // floating card competing for height. @@ -219,12 +220,9 @@ struct BottomDictationBar: View { .foregroundStyle(palette.textTertiary) } .font(MacSettingsType.control) - .padding(.horizontal, Spacing.sm) + .padding(.horizontal, Spacing.md) .frame(minHeight: MacMetrics.settingsControlHeight) - .background( - palette.surfaceElevated, - in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) - ) + .background(palette.surfaceElevated, in: Capsule()) } .menuStyle(.borderlessButton) .fixedSize() @@ -247,16 +245,24 @@ struct BottomDictationBar: View { .animation(Motion.quick, value: viewModel.isRecording) } + /// 胶囊按钮内、麦克风右侧的快捷键指引:「⌥ 按住 Option」。 + private var optionHoldHint: some View { + HStack(spacing: Spacing.xxs) { + Image(systemName: "option") + .font(.system(size: 12, weight: .semibold)) + Text(MacL10n.string("mac.hint.holdOption.compact", language: lang)) + .font(TypeStyle.caption) + .lineLimit(1) + } + .foregroundStyle(palette.textOnAccent.opacity(0.9)) + .fixedSize() + .accessibilityElement(children: .combine) + } + private var recordButton: some View { Button(action: viewModel.toggleRecording) { - ZStack { - Circle() - .fill(viewModel.isRecording ? palette.recordRed : palette.accent) - .frame(width: 52, height: 52) - .shadow( - color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35), - radius: pulse ? 10 : 5 - ) + // 胶囊按钮:左侧麦克风(录音时为波形),右侧「按住 Option」提示。 + HStack(spacing: Spacing.sm) { Group { if viewModel.isRecording { MiniWaveform(level: viewModel.audioLevel, barCount: 4, tint: palette.textOnAccent) @@ -266,8 +272,25 @@ struct BottomDictationBar: View { .foregroundStyle(palette.textOnAccent) } } + .frame(width: 24) .transition(.opacity.combined(with: .scale(scale: 0.7))) + + if !viewModel.isRecording { + optionHoldHint + .transition(.opacity.combined(with: .move(edge: .trailing))) + } } + .padding(.horizontal, Spacing.md) + .frame(height: 52) + .background( + (viewModel.isRecording ? palette.recordRed : palette.accent), + in: Capsule() + ) + .shadow( + color: (viewModel.isRecording ? palette.recordRed : palette.accent).opacity(0.35), + radius: pulse ? 10 : 5 + ) + .contentShape(Capsule()) } .buttonStyle(.plain) .disabled(viewModel.isProcessing) diff --git a/OSGKeyboardMac/Info.plist b/OSGKeyboardMac/Info.plist index 863982a..4bcde3b 100644 --- a/OSGKeyboardMac/Info.plist +++ b/OSGKeyboardMac/Info.plist @@ -46,5 +46,7 @@ OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider. NSSpeechRecognitionUsageDescription OSGKeyboard uses on-device speech recognition for local dictation mode. + OSGPrivateKeychainAccessGroup + $(AppIdentifierPrefix)com.osgkeyboard.mac diff --git a/OSGKeyboardMac/MacAccountAppleButton.swift b/OSGKeyboardMac/MacAccountAppleButton.swift new file mode 100644 index 0000000..f106482 --- /dev/null +++ b/OSGKeyboardMac/MacAccountAppleButton.swift @@ -0,0 +1,141 @@ +// MacAccountAppleButton.swift +// OSGKeyboard · Mac +// +// Sign in with Apple for the menu-bar app. Sign-in and delete-account +// reauthentication share one button, exactly like the iOS account center. +// Nonce generation and credential validation come from the cross-platform +// `AccountAppleNonce` / `AccountAppleAuthorizationPayload` factories, so the +// security-relevant path is identical on both platforms — only the labels, +// which resolve from Shared.strings here instead of the iOS app bundle, differ. +// +// This works in a non-sandboxed Developer ID build: Sign in with Apple needs +// only the `com.apple.developer.applesignin` entitlement, never the Mac App +// Store. The Mac App ID must be grouped under the iOS primary App ID or the +// same person authenticates as a different Apple `sub` here than on iPhone. + +import AuthenticationServices +import SwiftUI + +struct MacAccountAppleButton: View { + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var coordinator: AccountSessionCoordinator + + let purpose: AccountAppleAuthorizationPurpose + let language: AppUILanguage + var onSignedIn: () -> Void = {} + var onDeleted: () -> Void = {} + + @State private var rawNonce: String? + + var body: some View { + Group { + if purpose == .signIn, coordinator.operation == .signingIn { + loadingButton + } else { + SignInWithAppleButton( + purpose == .signIn ? .signIn : .continue, + onRequest: prepareRequest, + onCompletion: completeAuthorization + ) + .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) + // 系统按钮的文字/图标随按钮高度缩放,40pt 时文字明显偏小。 + .frame(height: MacMetrics.appleButtonHeight) + .clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .accessibilityIdentifier( + purpose == .signIn + ? "mac.account.signIn.apple" + : "mac.account.delete.reauthenticate" + ) + } + } + // Fixed, not `maxWidth`: inside a settings row the label column is also + // flexible, and two flexible siblings split the row evenly — which + // shrank the button and squeezed the copy next to it. + .frame(width: 260) + } + + private var loadingButton: some View { + HStack(spacing: Spacing.sm) { + ProgressView() + .controlSize(.small) + .tint(foreground) + Text(MacL10n.string("mac.account.signIn.loading", language: language)) + .font(TypeStyle.bodyEmph) + } + .foregroundStyle(foreground) + .frame(maxWidth: .infinity) + .frame(height: MacMetrics.appleButtonHeight) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("mac.account.signIn.loading") + } + + private var background: Color { colorScheme == .dark ? .white : .black } + private var foreground: Color { colorScheme == .dark ? .black : .white } + + private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) { + guard let nonce = AccountAppleNonce.make() else { + rawNonce = nil + request.nonce = nil + return + } + rawNonce = nonce.rawValue + request.requestedScopes = purpose == .signIn ? [.fullName] : [] + request.nonce = nonce.sha256Hex + } + + private func completeAuthorization(_ result: Result) { + guard let rawNonce else { + OSGDiag.log( + "appleAuthorization failed stage=nonce-unavailable", + category: "account" + ) + coordinator.recordAppleAuthorizationFailure(detail: "nonce-unavailable") + return + } + self.rawNonce = nil + + switch result { + case let .success(authorization): + guard let payload = AccountAppleAuthorizationPayload.make( + from: authorization, + rawNonce: rawNonce + ) else { + OSGDiag.log( + "appleAuthorization failed stage=credential-payload", + category: "account" + ) + coordinator.recordAppleAuthorizationFailure(detail: "credential-payload") + return + } + Task { + switch purpose { + case .signIn: + await coordinator.signIn(with: payload) + if coordinator.isSignedIn { + ProviderConfig.shared.reloadFromPersistedStorage() + onSignedIn() + } + case .deleteAccount: + await coordinator.deleteAccount(with: payload) + if !coordinator.isSignedIn { + onDeleted() + } + } + } + case let .failure(error): + let diagnostic = error as NSError + OSGDiag.log( + "appleAuthorization failed stage=system " + + "domain=\(diagnostic.domain) code=\(diagnostic.code)", + category: "account" + ) + if (error as? ASAuthorizationError)?.code != .canceled { + coordinator.recordAppleAuthorizationFailure( + detail: "as-\(diagnostic.domain)-\(diagnostic.code)" + ) + } + } + } +} diff --git a/OSGKeyboardMac/MacAccountView.swift b/OSGKeyboardMac/MacAccountView.swift new file mode 100644 index 0000000..38d380a --- /dev/null +++ b/OSGKeyboardMac/MacAccountView.swift @@ -0,0 +1,363 @@ +// MacAccountView.swift +// OSGKeyboard · Mac +// +// `MacAccountSettingsSection` — the account card that opens the Settings +// page: Sign in with Apple, credit balance, and the permanent invitation +// link. Everything here reuses the iOS +// account stack (`AccountSessionCoordinator`, `LiveAccountServices`, +// `ReferralProfileViewModel`) compiled at source level — see project.yml. +// +// Unlike iOS there is no separate Account tab. macOS has one settings surface, +// so the account is simply the first `MacSettingsSection` on it and every row +// sits on the same grid (`settingsCardInset` + `settingsRowMinHeight`) as the +// provider rows below — the old standalone page stacked free-floating VStacks +// that lined up with nothing. +// +// Deliberately no purchase UI. A Developer ID build cannot use StoreKit, so +// credits are bought on iPhone or the web and only *spent* here; the balance +// is server-authoritative either way. + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +struct MacAccountSettingsSection: View { + @EnvironmentObject private var coordinator: AccountSessionCoordinator + @Environment(\.themePalette) private var palette + + let language: AppUILanguage + + @State private var isConfirmingDelete = false + + private var lang: AppUILanguage { language } + + var body: some View { + MacSettingsSection( + title: MacL10n.string("mac.section.account", language: lang), + footer: footer + ) { + VStack(spacing: MacMetrics.settingsRowGap) { + switch coordinator.sessionPhase { + case .restoring: + statusRow(MacL10n.string("mac.account.restoring", language: lang)) + case .signedOut: + signedOutRow + case let .signedIn(session): + identityRow(session) + creditRows + MacInviteLinkRow(profile: coordinator.referralProfile, language: lang) + referralCountRows + accountActionsRow + deleteRows + } + } + } + .task { await coordinator.restoreIfNeeded() } + .alert( + MacL10n.string("mac.account.error.title", language: lang), + isPresented: operationErrorBinding + ) { + Button(MacL10n.string("mac.account.error.dismiss", language: lang)) { + coordinator.dismissOperationError() + } + } message: { + if let key = coordinator.operationErrorKey { + // The code is appended verbatim: on Mac there is no TestFlight + // crash trail behind a failed sign-in, and "登录失败,请重试" + // alone is not something anyone can act on. + Text(alertMessage(for: key)) + } + } + } + + private var footer: String { + switch coordinator.sessionPhase { + case .signedOut, .restoring: + return MacL10n.string("mac.account.signedOut.footnote", language: lang) + case .signedIn: + return MacL10n.string("mac.account.credits.purchaseNote", language: lang) + } + } + + private func alertMessage(for key: String) -> String { + let message = MacL10n.string(key, language: lang) + guard let detail = coordinator.operationErrorDetail else { return message } + return "\(message)\n(\(detail))" + } + + // MARK: - Signed out + + /// Title + explanation on the left, the Apple button right-aligned — the + /// same two-column shape every other settings row uses. + private var signedOutRow: some View { + MacInlineRow( + title: MacL10n.string("mac.account.signedOut.title", language: lang), + subtitle: MacL10n.string("mac.account.signedOut.body", language: lang) + ) { + MacAccountAppleButton(purpose: .signIn, language: lang) + } + } + + // MARK: - Signed in + + private func identityRow(_ session: AccountSession) -> some View { + MacInlineRow( + title: session.displayName?.isEmpty == false + ? session.displayName! + : MacL10n.string("mac.account.profile.unnamed", language: lang), + subtitle: MacL10n.format( + "mac.account.profile.accountID", + language: lang, + String(session.accountID.uuidString.prefix(8)).lowercased() + ) + ) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.accent) + } + } + + @ViewBuilder + private var creditRows: some View { + switch coordinator.snapshotPhase { + case .idle, .loading: + statusRow(MacL10n.string("mac.account.credits.loading", language: lang)) + + case let .loaded(snapshot): + MacInlineRow(title: MacL10n.string("mac.account.credits.balance", language: lang)) { + Text("\(snapshot.credits.balance)") + .font(TypeStyle.title3) + .monospacedDigit() + .foregroundStyle(palette.accent) + } + MacInlineRow(title: MacL10n.string("mac.account.credits.used", language: lang)) { + Text("\(snapshot.credits.usedCredits)") + .font(MacSettingsType.rowLabel) + .monospacedDigit() + .foregroundStyle(palette.textSecondary) + } + + case let .failed(messageKey): + MacInlineRow( + title: MacL10n.string("mac.account.credits.balance", language: lang), + subtitle: MacL10n.string(messageKey, language: lang) + ) { + EmptyView() + } + } + } + + @ViewBuilder + private var referralCountRows: some View { + if case let .loaded(snapshot) = coordinator.snapshotPhase { + let summary = AccountReferralSummary(referrals: snapshot.referrals) + MacInlineRow(title: MacL10n.string("mac.account.invite.rewarded", language: lang)) { + Text("\(summary.rewarded)") + .font(MacSettingsType.rowLabel) + .monospacedDigit() + .foregroundStyle(palette.textSecondary) + } + MacInlineRow(title: MacL10n.string("mac.account.invite.pending", language: lang)) { + Text("\(summary.pending)") + .font(MacSettingsType.rowLabel) + .monospacedDigit() + .foregroundStyle(palette.textSecondary) + } + } + } + + /// Refresh / sign out share one row so the card ends on the row grid rather + /// than on two stacked full-width buttons. + private var accountActionsRow: some View { + HStack(spacing: Spacing.lg) { + linkButton( + MacL10n.string("mac.account.credits.refresh", language: lang), + isBusy: coordinator.isRefreshingAccountData + ) { + Task { await coordinator.refreshAccountData(force: true) } + } + linkButton( + MacL10n.string("mac.account.signOut", language: lang), + isBusy: coordinator.operation == .signingOut + ) { + Task { await coordinator.signOut() } + } + Spacer(minLength: 0) + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + } + + @ViewBuilder + private var deleteRows: some View { + if isConfirmingDelete { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text(MacL10n.string("mac.account.delete.confirmBody", language: lang)) + .font(MacSettingsType.hint) + .foregroundStyle(palette.danger) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: Spacing.lg) { + // Apple requires re-authentication before deletion, so this + // is a second Sign in with Apple pass, not a confirm button. + MacAccountAppleButton( + purpose: .deleteAccount, + language: lang, + onDeleted: { isConfirmingDelete = false } + ) + Button(MacL10n.string("mac.account.delete.cancel", language: lang)) { + isConfirmingDelete = false + } + .buttonStyle(.plain) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textSecondary) + Spacer(minLength: 0) + } + } + .padding(.horizontal, MacMetrics.settingsCardInset) + } else { + MacInlineRow( + title: MacL10n.string("mac.account.delete.title", language: lang), + subtitle: MacL10n.string("mac.account.delete.body", language: lang) + ) { + Button(MacL10n.string("mac.account.delete.action", language: lang)) { + isConfirmingDelete = true + } + .buttonStyle(.plain) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.danger) + .accessibilityIdentifier("mac.account.delete.action") + } + } + } + + // MARK: - Helpers + + private func statusRow(_ text: String) -> some View { + HStack(spacing: Spacing.sm) { + ProgressView().controlSize(.small) + Text(text) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textSecondary) + Spacer(minLength: 0) + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + } + + private func linkButton( + _ title: String, + isBusy: Bool, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: Spacing.xs) { + Button(title, action: action) + .buttonStyle(.plain) + .font(MacSettingsType.rowLabel) + .foregroundStyle(isBusy ? palette.textTertiary : palette.accent) + .disabled(isBusy) + if isBusy { + ProgressView().controlSize(.small) + } + } + } + + private var operationErrorBinding: Binding { + Binding( + get: { coordinator.operationErrorKey != nil }, + set: { isPresented in + if !isPresented { coordinator.dismissOperationError() } + } + ) + } +} + +// MARK: - Invitation link + +/// Separate view so the nested `ReferralProfileViewModel` is observed +/// directly — the parent only observes `AccountSessionCoordinator`, and +/// SwiftUI does not propagate a child ObservableObject's changes through it. +private struct MacInviteLinkRow: View { + @ObservedObject var profile: ReferralProfileViewModel + @Environment(\.themePalette) private var palette + + let language: AppUILanguage + + @State private var didCopy = false + @State private var copyResetTask: Task? + + var body: some View { + content + .onDisappear { copyResetTask?.cancel() } + } + + @ViewBuilder + private var content: some View { + switch profile.state { + case .idle, .loading: + HStack(spacing: Spacing.sm) { + ProgressView().controlSize(.small) + Text(MacL10n.string("mac.account.invite.loading", language: language)) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textSecondary) + Spacer(minLength: 0) + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + + case let .loaded(loaded): + MacInlineRow( + title: MacL10n.string("mac.account.invite.link", language: language), + subtitle: loaded.code.inviteURL.absoluteString + ) { + Button { + copy(loaded.code.inviteURL) + } label: { + Label( + MacL10n.string( + didCopy + ? "mac.account.invite.copied" + : "mac.account.invite.copy", + language: language + ), + systemImage: didCopy ? "checkmark" : "doc.on.doc" + ) + .font(MacSettingsType.rowLabel) + } + .buttonStyle(.plain) + .foregroundStyle(palette.accent) + .accessibilityIdentifier("mac.account.invite.copy") + } + + case let .failed(messageKey): + MacInlineRow( + title: MacL10n.string("mac.account.invite.link", language: language), + subtitle: MacL10n.string(messageKey, language: language) + ) { + Button(MacL10n.string("mac.account.invite.retry", language: language)) { + Task { await profile.refresh() } + } + .buttonStyle(.plain) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.accent) + } + } + } + + private func copy(_ url: URL) { + #if os(macOS) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(url.absoluteString, forType: .string) + #endif + didCopy = true + copyResetTask?.cancel() + copyResetTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + guard !Task.isCancelled else { return } + didCopy = false + } + } +} diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index 33dd9b7..956a15f 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -27,6 +27,9 @@ enum MacMetrics { /// label. Content is centered within it; taller rows (status text, progress) /// grow past it. static let settingsRowMinHeight: CGFloat = 40 + /// Sign in with Apple 按钮高度:系统按钮的字号随高度缩放,44 让文字与图标 + /// 与设置页 15pt 正文观感一致(40 时明显偏小)。 + static let appleButtonHeight: CGFloat = 44 /// The single vertical rhythm for settings cards: the gap *between* rows AND /// the padding between a card's top/bottom edge and its first/last row are /// both this value, so the card breathes evenly. Applied as `VStack(spacing:)` @@ -62,10 +65,20 @@ enum MacMetrics { /// `pageHorizontalInset` on the Settings Form so its card outer edge lands /// on `pageHorizontalInset` — matching the History page and the page title. static let groupedFormSectionInset: CGFloat = Spacing.lg - /// Default (= minimum) main-window size. Opening the app uses this size; - /// the window cannot shrink below it. + /// Smallest the main window may be dragged to. Every page stays usable + /// (and scrollable) at this size, but nothing is laid out *for* it. + /// + /// The height floor also repairs windows restored from saved state: + /// `defaultSize` only applies on a window's first appearance, so anyone + /// who already ran the app keeps the old frame until the minimum lifts it. static let windowMinWidth: CGFloat = 860 - static let windowMinHeight: CGFloat = 600 + static let windowMinHeight: CGFloat = 680 + /// Size the window opens at. Deliberately larger than the minimum: at + /// 860×600 the onboarding hero + step card + progress dots + bottom bar + /// do not all fit, so the primary action fell below the window edge. + /// Settings and Onboarding both fit comfortably at this size. + static let windowDefaultWidth: CGFloat = 1_100 + static let windowDefaultHeight: CGFloat = 800 /// Compact dictation-canvas height so Home fits the min window without scrolling. static let dictationCanvasMinHeight: CGFloat = 120 /// Top inset that clears the window traffic-light buttons now that the diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index c3123f2..a137faa 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -14,6 +14,8 @@ enum MacSection: String, CaseIterable, Identifiable { case history case dictionary case styles + // No `account` case: on Mac the account lives in Settings as its first + // card (iOS keeps a dedicated tab). One sign-in surface, one page. case settings var id: String { rawValue } @@ -183,10 +185,6 @@ final class MacDictationViewModel: ObservableObject { LLMProvider.asrSelectablePresets } - var selectableProviders: [LLMProvider] { - asrSelectableProviders - } - var dictionaryTermCount: Int { _ = dictionaryRevision return AppGroupStore(defaults: defaults).personalDictionary.entries.count @@ -268,6 +266,15 @@ final class MacDictationViewModel: ObservableObject { if enabled { hotkeyService.start() } else { hotkeyService.stop() } } + /// Called when Accessibility flips to trusted (see `MacPermissionMonitor`). + /// Granting the permission does not retro-attach the global event monitor, + /// so the hotkey has to be re-armed or Option-hold keeps doing nothing + /// until the app is relaunched. + func reattachHotkeyIfNeeded() { + guard hotkeyEnabled else { return } + hotkeyService.restartIfGlobalMonitorMissing() + } + func setHotkeyTrigger(_ trigger: MacHotkeyTrigger) { hotkeyTrigger = trigger defaults.set(trigger.rawValue, forKey: StoredKeys.hotkeyTrigger) diff --git a/OSGKeyboardMac/MacHotkeyService.swift b/OSGKeyboardMac/MacHotkeyService.swift index 0554cf3..c9ba5a7 100644 --- a/OSGKeyboardMac/MacHotkeyService.swift +++ b/OSGKeyboardMac/MacHotkeyService.swift @@ -83,6 +83,24 @@ final class MacHotkeyService { if !enabled { cancelPress() } } + /// True once the *global* `.flagsChanged` monitor is attached, i.e. once + /// Option-hold works outside the app's own windows. False whenever + /// Accessibility was denied at the time `start()` ran. + var isGlobalMonitorActive: Bool { globalFlagsMonitor != nil } + + /// Re-attaches the global monitor after Accessibility was granted. + /// + /// `start()` alone cannot do this: when Accessibility is denied, + /// `addGlobalMonitorForEvents` returns nil while the *local* monitor still + /// attaches, so its "already started" guard would short-circuit every + /// retry and the hotkey stayed dead until the next launch. + func restartIfGlobalMonitorMissing() { + guard isEnabled, globalFlagsMonitor == nil else { return } + guard MacTextInsertionService.isAccessibilityTrusted else { return } + stop() + start() + } + func start() { guard globalFlagsMonitor == nil, localFlagsMonitor == nil else { return } // Global monitors require Accessibility; without it the call returns diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index 903b9ae..dc46681 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -6,6 +6,11 @@ // The current local runtime defaults to Qwen3 MLX with Apple Speech fallback; // Sherpa identifiers and install records are retained only for migration compatibility. // +// Step order mirrors iOS: permissions → engine → the recognition credentials +// that engine needs → the polish LLM. Speech recognition and polishing are two +// different providers with two different keys (iOS splits them across +// `APISetupPage` and `PolishSetupPage`), so they get one step each here too. +// // Visual language mirrors the iOS onboarding: an ambient top gradient, a // glowing hero icon, a large title block, and elongated capsule progress // dots — all carried by whitespace and a single accent colour. @@ -23,8 +28,12 @@ private enum MacOnboardingStep: Int, CaseIterable { case microphone case accessibility case engine - case cloudAPI + /// Cloud *speech recognition* credentials — shown only in cloud mode. + case cloudASR + /// Local ASR model download — shown only in local mode. case localModel + /// Polish / translation / AI LLM credentials — shown in both modes. + case polish var systemImage: String { switch self { @@ -32,8 +41,9 @@ private enum MacOnboardingStep: Int, CaseIterable { case .microphone: return "mic.fill" case .accessibility: return "accessibility" case .engine: return "switch.2" - case .cloudAPI: return "key.fill" + case .cloudASR: return "waveform" case .localModel: return "arrow.down.circle.fill" + case .polish: return "wand.and.stars" } } } @@ -41,8 +51,6 @@ private enum MacOnboardingStep: Int, CaseIterable { @MainActor private final class MacOnboardingViewModel: ObservableObject { @Published var step: MacOnboardingStep = .welcome - @Published var micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - @Published var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted @Published var catalog: LocalASRCatalogDocument? @Published var installProgress = LocalASRModelInstallProgress.idle @Published var isInstalling = false @@ -67,31 +75,6 @@ private final class MacOnboardingViewModel: ObservableObject { func reload() { catalog = try? LocalASRModelCatalog.loadBundled() - micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted - } - - func requestMicrophone() { - AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in - Task { @MainActor in - self?.micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - } - } - } - - func openAccessibilitySettings() { - _ = MacTextInsertionService.requestAccessibilityIfNeeded() - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) - } - refreshAccessibilitySoon() - } - - func refreshAccessibilitySoon() { - accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted - } } func installDefaultModel() { @@ -165,49 +148,62 @@ struct MacOnboardingView: View { @Environment(\.themePalette) private var palette @Environment(\.colorScheme) private var colorScheme @StateObject private var model = MacOnboardingViewModel() + // Microphone and Accessibility are both granted outside the app. Poll for + // them rather than sampling once — see `MacPermissionMonitor`. + @StateObject private var permissions = MacPermissionMonitor() @State private var contentAppeared = false private var lang: AppUILanguage { viewModel.config.uiLanguage } private var visibleSteps: [MacOnboardingStep] { - if viewModel.config.engineMode == "cloud" { - return [.welcome, .microphone, .accessibility, .engine, .cloudAPI] - } - return [.welcome, .microphone, .accessibility, .engine, .localModel] + var steps: [MacOnboardingStep] = [.welcome, .microphone, .accessibility, .engine] + steps.append(viewModel.config.engineMode == "cloud" ? .cloudASR : .localModel) + // Polishing runs on a cloud LLM in both engine modes, so its provider + // is configured either way. + steps.append(.polish) + return steps } var body: some View { - GeometryReader { geo in - ZStack(alignment: .top) { + ZStack(alignment: .top) { + GeometryReader { geo in background(height: geo.size.height) + } - VStack(spacing: 0) { - Spacer(minLength: Spacing.xl) - - hero - .id(model.step) - .transition(stepTransition) - - Spacer(minLength: Spacing.lg) - - progressDots - .padding(.bottom, Spacing.lg) - - bottomBar - .padding(.horizontal, Spacing.xxxl) - .padding(.bottom, Spacing.xxl) + VStack(spacing: 0) { + // The hero scrolls; the dots and the bottom bar are pinned so + // the primary action can never fall below the window edge — + // which is exactly what happened at the minimum window size. + GeometryReader { scrollGeo in + ScrollView { + hero + .id(model.step) + .transition(stepTransition) + .frame(maxWidth: .infinity) + .padding(.vertical, Spacing.xl) + .frame(minHeight: scrollGeo.size.height, alignment: .center) + } + .scrollBounceBehavior(.basedOnSize) } - .frame(maxWidth: .infinity) + + progressDots + .padding(.bottom, Spacing.lg) + + bottomBar + .padding(.horizontal, Spacing.xxxl) + .padding(.bottom, Spacing.xxl) } } .frame(minWidth: MacMetrics.windowMinWidth, minHeight: MacMetrics.windowMinHeight) .onAppear { applyDefaults() model.reload() + permissions.start() withAnimation(.spring(response: 0.7, dampingFraction: 0.85)) { contentAppeared = true } } + .onDisappear { permissions.stop() } } // MARK: Background @@ -306,22 +302,24 @@ struct MacOnboardingView: View { featureList case .microphone: permissionCard( - isGranted: model.micStatus == .authorized, + isGranted: permissions.microphoneStatus == .authorized, grantedText: MacL10n.string("mac.onboarding.microphone.granted", language: lang), neededText: MacL10n.string("mac.onboarding.microphone.needed", language: lang) ) case .accessibility: permissionCard( - isGranted: model.accessibilityTrusted, + isGranted: permissions.isAccessibilityTrusted, grantedText: MacL10n.string("mac.onboarding.accessibility.granted", language: lang), neededText: MacL10n.string("mac.onboarding.accessibility.needed", language: lang) ) case .engine: enginePicker - case .cloudAPI: - cloudAPIFields + case .cloudASR: + cloudASRFields case .localModel: localModelPanel + case .polish: + polishFields } } @@ -363,12 +361,14 @@ struct MacOnboardingView: View { Text(isGranted ? grantedText : neededText) .font(TypeStyle.bodyEmph) .foregroundStyle(palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) + .animation(Motion.quick, value: isGranted) } private var enginePicker: some View { @@ -428,40 +428,94 @@ struct MacOnboardingView: View { .buttonStyle(.plain) } - private var cloudAPIFields: some View { + /// Cloud speech-recognition credentials. Distinct from `polishFields` + /// below: this writes the `asr*` config, and its provider list is the + /// cloud-ASR allowlist, not the LLM presets. + private var cloudASRFields: some View { + credentialCard( + providerLabel: MacL10n.string("mac.settings.asrService", language: lang), + providers: viewModel.asrSelectableProviders, + providerSelection: asrProviderBinding, + keyLabel: MacL10n.string("mac.settings.asrApiKey", language: lang), + key: $viewModel.config.asrApiKey, + modelLabel: MacL10n.string("mac.settings.asrModel", language: lang), + modelPlaceholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId), + model: $viewModel.config.asrModel, + hint: MacL10n.string("mac.onboarding.cloud.skipHint", language: lang) + ) + } + + /// Polish / translation / AI LLM credentials — the step that was missing + /// on Mac. Onboarding previously offered only the ASR provider list while + /// writing into the *polish* config fields, so the two were never both set. + private var polishFields: some View { + credentialCard( + providerLabel: MacL10n.string("mac.settings.service", language: lang), + providers: viewModel.polishSelectableProviders, + providerSelection: polishProviderBinding, + keyLabel: MacL10n.string("mac.settings.apiKey", language: lang), + key: $viewModel.config.apiKey, + modelLabel: MacL10n.string("mac.settings.model", language: lang), + modelPlaceholder: currentPolishProvider.defaultModel, + model: $viewModel.config.model, + hint: MacL10n.string("mac.onboarding.polish.skipHint", language: lang) + ) + } + + private func credentialCard( + providerLabel: String, + providers: [LLMProvider], + providerSelection: Binding, + keyLabel: String, + key: Binding, + modelLabel: String, + modelPlaceholder: String, + model modelText: Binding, + hint: String + ) -> some View { VStack(alignment: .leading, spacing: Spacing.md) { + fieldLabel(providerLabel) MacInlinePicker( - selection: providerBinding, - options: viewModel.selectableProviders.map { + selection: providerSelection, + options: providers.map { MacInlinePickerOption( value: $0.id, label: ProviderDisplayName.name(for: $0.id, language: lang) ) - } + }, + fillsWidth: true ) - .frame(maxWidth: .infinity, alignment: .leading) - SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) + fieldLabel(keyLabel) + SecureField(text: key, prompt: Text(verbatim: "sk-…")) { + Text(keyLabel) } .labelsHidden() .macFieldStyle() - TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.model", language: lang)) + fieldLabel(modelLabel) + TextField(text: modelText, prompt: Text(verbatim: modelPlaceholder)) { + Text(modelLabel) } .labelsHidden() .macFieldStyle() - Label(MacL10n.string("mac.onboarding.cloud.skipHint", language: lang), systemImage: "info.circle") + Label(hint, systemImage: "info.circle") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) } .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) } + private func fieldLabel(_ text: String) -> some View { + Text(text) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + } + private var localModelPanel: some View { VStack(alignment: .leading, spacing: Spacing.md) { HStack(spacing: Spacing.sm) { @@ -507,6 +561,7 @@ struct MacOnboardingView: View { Label(MacL10n.string("mac.onboarding.model.skipHint", language: lang), systemImage: "info.circle") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) } .padding(Spacing.md) .frame(maxWidth: .infinity) @@ -540,20 +595,27 @@ struct MacOnboardingView: View { } } - Spacer(minLength: 0) + Spacer(minLength: Spacing.md) primaryButton(primaryButtonTitle, disabled: model.isInstalling && model.step == .localModel) { primaryAction() } } - .frame(maxWidth: 520) + // Wide enough that three CJK labels ("上一步 · 暂时跳过 · 打开系统设置") + // still fit on one line at the minimum window width. + .frame(maxWidth: 640) .frame(maxWidth: .infinity) } private func primaryButton(_ titleText: String, disabled: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { + // `lineLimit(1)` + `fixedSize` keep the label on one line: without + // them a long CJK title ("打开系统设置") wrapped inside the fixed + // 44pt pill and spilled outside the capsule. Text(titleText) .font(TypeStyle.headline) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .foregroundStyle(disabled ? palette.textSecondary : palette.textOnAccent) .padding(.horizontal, Spacing.xxl) .frame(minWidth: 150, minHeight: 44) @@ -570,6 +632,8 @@ struct MacOnboardingView: View { Button(action: action) { Text(titleText) .font(TypeStyle.bodyEmph) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) .foregroundStyle(palette.textSecondary) .padding(.horizontal, Spacing.lg) .frame(minHeight: 44) @@ -589,8 +653,9 @@ struct MacOnboardingView: View { case .microphone: return MacL10n.string("mac.onboarding.microphone.title", language: lang) case .accessibility: return MacL10n.string("mac.onboarding.accessibility.title", language: lang) case .engine: return MacL10n.string("mac.onboarding.engine.title", language: lang) - case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.title", language: lang) + case .cloudASR: return MacL10n.string("mac.onboarding.cloud.title", language: lang) case .localModel: return MacL10n.string("mac.onboarding.model.title", language: lang) + case .polish: return MacL10n.string("mac.onboarding.polish.title", language: lang) } } @@ -600,23 +665,24 @@ struct MacOnboardingView: View { case .microphone: return MacL10n.string("mac.onboarding.microphone.subtitle", language: lang) case .accessibility: return MacL10n.string("mac.onboarding.accessibility.subtitle", language: lang) case .engine: return MacL10n.string("mac.onboarding.engine.subtitle", language: lang) - case .cloudAPI: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang) + case .cloudASR: return MacL10n.string("mac.onboarding.cloud.subtitle", language: lang) case .localModel: return MacL10n.string("mac.onboarding.model.subtitle", language: lang) + case .polish: return MacL10n.string("mac.onboarding.polish.subtitle", language: lang) } } private var primaryButtonTitle: String { switch model.step { - case .microphone where model.micStatus != .authorized: + case .microphone where permissions.microphoneStatus != .authorized: return MacL10n.string("mac.onboarding.microphone.allow", language: lang) - case .accessibility where !model.accessibilityTrusted: + case .accessibility where !permissions.isAccessibilityTrusted: return MacL10n.string("mac.onboarding.accessibility.open", language: lang) - case .cloudAPI: - return MacL10n.string("mac.onboarding.finish", language: lang) - case .localModel: - return MacL10n.string(model.isDefaultModelInstalled ? "mac.onboarding.finish" : "mac.onboarding.skipForNow", language: lang) + case .localModel where !model.isDefaultModelInstalled: + return MacL10n.string("mac.onboarding.skipForNow", language: lang) default: - return isLastStep ? MacL10n.string("mac.onboarding.finish", language: lang) : MacL10n.string("mac.onboarding.next", language: lang) + return isLastStep + ? MacL10n.string("mac.onboarding.finish", language: lang) + : MacL10n.string("mac.onboarding.next", language: lang) } } @@ -628,16 +694,34 @@ struct MacOnboardingView: View { return ByteCountFormatter.string(fromByteCount: Int64(model.sizeBytes), countStyle: .file) } - private var providerBinding: Binding { + private var currentPolishProvider: LLMProvider { + viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId } + ?? viewModel.polishSelectableProviders.first + ?? LLMProvider.presets[0] + } + + private var polishProviderBinding: Binding { Binding( get: { viewModel.config.providerId }, set: { newId in - guard let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) else { return } + guard let provider = viewModel.polishSelectableProviders + .first(where: { $0.id == newId }) else { return } viewModel.selectProvider(provider) } ) } + private var asrProviderBinding: Binding { + Binding( + get: { viewModel.config.asrProviderId }, + set: { newId in + guard let provider = viewModel.asrSelectableProviders + .first(where: { $0.id == newId }) else { return } + viewModel.selectAsrProvider(provider) + } + ) + } + // MARK: Derived private var cardShape: RoundedRectangle { @@ -660,7 +744,10 @@ struct MacOnboardingView: View { } private var canSkipCurrentStep: Bool { - model.step != .welcome && !model.isInstalling + guard model.step != .welcome, !model.isInstalling else { return false } + // On the local-model step the primary button already reads "Skip for + // now"; a second identical button next to it reads as a bug. + return !(model.step == .localModel && !model.isDefaultModelInstalled) } private var isLastStep: Bool { @@ -675,10 +762,10 @@ struct MacOnboardingView: View { private func primaryAction() { switch model.step { - case .microphone where model.micStatus != .authorized: - model.requestMicrophone() - case .accessibility where !model.accessibilityTrusted: - model.openAccessibilitySettings() + case .microphone where permissions.microphoneStatus != .authorized: + permissions.requestMicrophone() + case .accessibility where !permissions.isAccessibilityTrusted: + permissions.openAccessibilitySettings() default: if isLastStep { finish() } else { goForward() } } diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index 7ae30ff..8a8e9af 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -17,7 +17,10 @@ struct MacSettingsView: View { private var appearanceRaw = MacAppearancePreference.system.rawValue @AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = true - @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + // Accessibility is granted in System Settings, which sends no notification + // back. The monitor polls while this page is on screen so the status flips + // as soon as the user returns, instead of on the next app launch. + @StateObject private var permissions = MacPermissionMonitor() private var lang: AppUILanguage { viewModel.config.uiLanguage } private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ @@ -39,7 +42,7 @@ struct MacSettingsView: View { ScrollView { LazyVStack(alignment: .leading, spacing: Spacing.xl) { - supportSection + MacAccountSettingsSection(language: lang) generalSection recognitionSection if viewModel.config.engineMode == "cloud" { @@ -63,7 +66,8 @@ struct MacSettingsView: View { } .background(palette.background) } - .onAppear { refreshAccessibilityState() } + .onAppear { permissions.start() } + .onDisappear { permissions.stop() } } // MARK: - General @@ -320,30 +324,23 @@ struct MacSettingsView: View { } Label( - accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, - systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" + permissions.isAccessibilityTrusted + ? accessibilityStatusGranted + : accessibilityStatusNeeded, + systemImage: permissions.isAccessibilityTrusted + ? "checkmark.circle.fill" + : "exclamationmark.circle" ) .font(TypeStyle.caption) - .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) + .foregroundStyle(permissions.isAccessibilityTrusted ? palette.accent : palette.warning) .contentTransition(.opacity) - .animation(Motion.quick, value: accessibilityTrusted) + .animation(Motion.quick, value: permissions.isAccessibilityTrusted) } } } } } - // MARK: - Support developer - - private var supportSection: some View { - MacSettingsSection( - title: MacL10n.string("tip.title", language: lang), - footer: MacL10n.string("tip.consumableNotice", language: lang) - ) { - MacSupportDeveloperTipRows(language: lang) - } - } - // MARK: - Legal private var legalSection: some View { @@ -361,12 +358,17 @@ struct MacSettingsView: View { MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) } .buttonStyle(.plain) - Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { + Button { hasCompletedMacOnboarding = false + } label: { + // 与相邻的链接行同款排版:15pt 主文字 + 同样的行高与内边距。 + MacFormLinkRow( + title: MacL10n.string("mac.settings.restartOnboarding", language: lang), + showsChevron: false + ) } .buttonStyle(.plain) - .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) - .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(maxWidth: .infinity, alignment: .leading) // Read-only version row at the bottom of About. MacInlineRow(title: MacL10n.string("mac.settings.version", language: lang)) { @@ -582,20 +584,7 @@ struct MacSettingsView: View { // MARK: - AppKit actions (macOS only) private func openAccessibilitySettings() { - #if os(macOS) - _ = MacTextInsertionService.requestAccessibilityIfNeeded() - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { - NSWorkspace.shared.open(url) - } - refreshAccessibilityState() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - refreshAccessibilityState() - } - #endif - } - - private func refreshAccessibilityState() { - accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + permissions.openAccessibilitySettings() } private func localeLabel(_ locale: (id: String, key: String, fallback: String)) -> String { diff --git a/OSGKeyboardMac/MacSupportDeveloperTipRows.swift b/OSGKeyboardMac/MacSupportDeveloperTipRows.swift deleted file mode 100644 index 79b46c7..0000000 --- a/OSGKeyboardMac/MacSupportDeveloperTipRows.swift +++ /dev/null @@ -1,91 +0,0 @@ -// MacSupportDeveloperTipRows.swift -// OSGKeyboard · Mac -// -// Optional consumable tip rows for macOS Settings. - -import SwiftUI - -struct MacSupportDeveloperTipRows: View { - @ObservedObject private var tipManager = TipPurchaseManager.shared - @Environment(\.themePalette) private var palette - - let language: AppUILanguage - - @State private var showThankYouAlert = false - @State private var showErrorAlert = false - @State private var errorMessage = "" - - var body: some View { - VStack(alignment: .leading, spacing: MacMetrics.settingsRowGap) { - SupportDeveloperTipBody(language: language) - .padding(.horizontal, MacMetrics.settingsCardInset) - - if tipManager.supportCount > 0 { - Text( - SharedL10n.format( - "tip.thankYou.past", - language: language, - tipManager.supportCount - ) - ) - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - .padding(.horizontal, MacMetrics.settingsCardInset) - } - - MacSettingsToolButton(title: tipButtonTitle, disabled: isPurchaseInFlight) { - Task { await tipManager.purchase() } - } - .padding(.horizontal, MacMetrics.settingsCardInset) - } - .onChange(of: tipManager.purchaseState) { _, newValue in - switch newValue { - case .succeeded: - showThankYouAlert = true - case .failed(let message): - errorMessage = message - showErrorAlert = true - default: - break - } - } - .alert( - SharedL10n.string("tip.thankYou.title", language: language), - isPresented: $showThankYouAlert - ) { - Button(SharedL10n.string("tip.alert.dismiss", language: language)) { - tipManager.acknowledgePurchaseState() - } - .tint(palette.textPrimary) - } message: { - Text(SharedL10n.string("tip.thankYou.message", language: language)) - } - .alert( - SharedL10n.string("tip.error.title", language: language), - isPresented: $showErrorAlert - ) { - Button(SharedL10n.string("tip.alert.dismiss", language: language)) { - tipManager.acknowledgePurchaseState() - } - .tint(palette.textPrimary) - } message: { - Text(errorMessage) - } - } - - private var isPurchaseInFlight: Bool { - switch tipManager.purchaseState { - case .loading, .purchasing: - return true - default: - return false - } - } - - private var tipButtonTitle: String { - if let product = tipManager.product { - return SharedL10n.format("tip.button", language: language, product.displayPrice) - } - return SharedL10n.string("tip.buttonFallback", language: language) - } -} diff --git a/OSGKeyboardMac/MacTextInsertionService.swift b/OSGKeyboardMac/MacTextInsertionService.swift index 1fe3cec..25e3131 100644 --- a/OSGKeyboardMac/MacTextInsertionService.swift +++ b/OSGKeyboardMac/MacTextInsertionService.swift @@ -5,10 +5,15 @@ // a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust. // Re-activates the app the user was dictating into (the popover steals // focus) and restores the original clipboard once the paste has landed. +// +// Also hosts `MacPermissionMonitor` — the live Accessibility / microphone +// authorization watcher shared by onboarding and Settings. +import AVFoundation import AppKit @preconcurrency import ApplicationServices import Carbon +import Combine import Foundation enum MacTextInsertionService { @@ -206,3 +211,100 @@ private final class FrontmostAppTracker: NSObject { lastExternalApp = app } } + +// MARK: - Live permission monitoring + +/// Publishes Accessibility / microphone authorization while a permission UI is +/// on screen. +/// +/// Both grants are made *outside* the app — in System Settings — and macOS +/// sends no notification when they flip. `AXIsProcessTrusted()` also keeps +/// returning the value captured when the process was launched for a short +/// while after the toggle, so a single delayed re-check (what onboarding and +/// Settings used to do) reads stale far more often than not. Polling on a +/// short timer plus re-reading on app activation is the only reliable way to +/// see the change without asking the user to relaunch. +@MainActor +final class MacPermissionMonitor: ObservableObject { + @Published private(set) var isAccessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted + @Published private(set) var microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio) + + /// Fast enough that returning from System Settings feels instantaneous, + /// slow enough to be free — `AXIsProcessTrusted` is a cheap XPC-free read. + private static let pollInterval: TimeInterval = 0.6 + + private var pollTask: Task? + /// Observed as an `AsyncSequence` rather than a block observer so the + /// registration is owned by the task and torn down by cancelling it — + /// a nonisolated `deinit` cannot hand a block observer back safely. + private var activationTask: Task? + + deinit { + pollTask?.cancel() + activationTask?.cancel() + } + + /// Begins polling. Safe to call repeatedly — extra calls are ignored. + func start() { + refresh() + guard pollTask == nil else { return } + + // Returning from System Settings usually means the grant just changed; + // refresh immediately instead of waiting out the poll interval. + activationTask = Task { [weak self] in + let activations = NotificationCenter.default.notifications( + named: NSApplication.didBecomeActiveNotification + ) + for await _ in activations { + guard !Task.isCancelled else { return } + await MainActor.run { self?.refresh() } + } + } + + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep( + nanoseconds: UInt64(Self.pollInterval * 1_000_000_000) + ) + guard !Task.isCancelled else { return } + self?.refresh() + } + } + } + + func stop() { + pollTask?.cancel() + pollTask = nil + activationTask?.cancel() + activationTask = nil + } + + func refresh() { + let trusted = MacTextInsertionService.isAccessibilityTrusted + if trusted != isAccessibilityTrusted { + isAccessibilityTrusted = trusted + // Showing a green checkmark is only half the grant: the global + // hotkey monitor was refused at launch and has to be re-attached. + if trusted { MacDictationViewModel.shared.reattachHotkeyIfNeeded() } + } + let mic = AVCaptureDevice.authorizationStatus(for: .audio) + if mic != microphoneStatus { microphoneStatus = mic } + } + + /// Prompts, then opens the Accessibility pane. Polling picks the grant up. + func openAccessibilitySettings() { + _ = MacTextInsertionService.requestAccessibilityIfNeeded() + if let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) { + NSWorkspace.shared.open(url) + } + refresh() + } + + func requestMicrophone() { + AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in + Task { @MainActor in self?.refresh() } + } + } +} diff --git a/OSGKeyboardMac/OSGKeyboardMac.entitlements b/OSGKeyboardMac/OSGKeyboardMac.entitlements index fa4ef6b..4a1f47c 100644 --- a/OSGKeyboardMac/OSGKeyboardMac.entitlements +++ b/OSGKeyboardMac/OSGKeyboardMac.entitlements @@ -2,17 +2,22 @@ + com.apple.developer.applesignin + + Default + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)com.osgkeyboard.ios com.apple.security.app-sandbox com.apple.security.device.audio-input com.apple.security.network.client - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)com.osgkeyboard.ios keychain-access-groups $(AppIdentifierPrefix)com.osgkeyboard.shared + $(AppIdentifierPrefix)com.osgkeyboard.mac diff --git a/OSGKeyboardMac/OSGKeyboardMacApp.swift b/OSGKeyboardMac/OSGKeyboardMacApp.swift index 6f41355..aaa7a31 100644 --- a/OSGKeyboardMac/OSGKeyboardMacApp.swift +++ b/OSGKeyboardMac/OSGKeyboardMacApp.swift @@ -12,6 +12,14 @@ import SwiftUI struct OSGKeyboardMacApp: App { @NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate @StateObject private var viewModel = MacDictationViewModel.shared + // Optional managed account: Sign in with Apple, server-held credits, and + // the invitation link. Unlike iOS there is no managed-gateway credential + // handoff to reset on sign-out — the Mac app is BYOK-only for providers, + // so the account only carries identity, credits and referrals. + @StateObject private var accountSession = AccountSessionCoordinator( + dependencies: LiveAccountDependencyFactory.make(), + arePurchasesAvailable: false + ) // Mac-local appearance preference. Drives both the SwiftUI colour scheme // and — via `applyToApp` — the AppKit window chrome / popover. @@ -36,8 +44,10 @@ struct OSGKeyboardMacApp: App { } .macSystemPalette() .environment(\.locale, viewModel.config.uiLanguage.swiftUILocale) + .environmentObject(accountSession) .preferredColorScheme(appearance.colorScheme) .task { await viewModel.onAppear() } + .task { await accountSession.restoreIfNeeded() } .onAppear { MacAppearancePreference.applyToApp(appearance) } .onChange(of: appearanceRaw) { MacAppearancePreference.applyToApp(appearance) } .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in @@ -53,6 +63,16 @@ struct OSGKeyboardMacApp: App { viewModel.speechHistory.reloadFromDisk() } .onOpenURL { url in + // Invitation links (https://osglab.com/i/). Reaching + // the app this way additionally needs the + // `applinks:osglab.com` associated-domains entitlement on + // the Mac App ID; without it the handler is simply never + // called and sharing your own link still works. + if accountSession.handleIncomingURL(url) { + // Account lives in Settings on Mac (first card). + viewModel.selectedSection = .settings + return + } #if DEBUG guard url.scheme == "osgkeyboard", url.host == "seed-demo" else { return } DemoDataSeeder.seedRichPlaceholderData( @@ -71,9 +91,10 @@ struct OSGKeyboardMacApp: App { // very top, matching macOS System Settings. .windowStyle(.hiddenTitleBar) .windowResizability(.contentMinSize) - // Open at the minimum size — same as `MacMetrics.windowMin*`, so the - // first launch already matches the smallest allowed window. - .defaultSize(width: MacMetrics.windowMinWidth, height: MacMetrics.windowMinHeight) + // Open larger than the minimum: onboarding's hero + step card + + // progress dots + bottom bar do not fit in `windowMin*`, which pushed + // the primary button off screen on first launch. + .defaultSize(width: MacMetrics.windowDefaultWidth, height: MacMetrics.windowDefaultHeight) } } diff --git a/OSGKeyboardShared/DesignSystem/UsageStatCard.swift b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift index 3b8ae74..5134c8b 100644 --- a/OSGKeyboardShared/DesignSystem/UsageStatCard.swift +++ b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift @@ -16,6 +16,8 @@ public struct UsageStatCard: View { public var accent: Bool /// Hero metric: wide horizontal layout for the primary word count. public var prominent: Bool + /// Stretch to the container's height (split layouts aligning a grid with a taller sibling). + public var expands: Bool public init( title: String, @@ -23,7 +25,8 @@ public struct UsageStatCard: View { caption: String, systemImage: String? = nil, accent: Bool = false, - prominent: Bool = false + prominent: Bool = false, + expands: Bool = false ) { self.title = title self.value = value @@ -31,6 +34,7 @@ public struct UsageStatCard: View { self.systemImage = systemImage self.accent = accent self.prominent = prominent + self.expands = expands } public var body: some View { @@ -69,7 +73,11 @@ public struct UsageStatCard: View { .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame( + maxWidth: .infinity, + maxHeight: expands ? .infinity : nil, + alignment: .topLeading + ) } /// Wide "hero bar": icon badge + title/caption left, big number right. diff --git a/OSGKeyboardShared/Services/ClipboardSemanticAnalyzer.swift b/OSGKeyboardShared/Services/ClipboardSemanticAnalyzer.swift index 31e229f..2ee65ab 100644 --- a/OSGKeyboardShared/Services/ClipboardSemanticAnalyzer.swift +++ b/OSGKeyboardShared/Services/ClipboardSemanticAnalyzer.swift @@ -166,8 +166,12 @@ public actor ClipboardSemanticAnalyzer { private static let resourceDirectory = "ClipboardSemantics" private static let manifestName = "clipboard-semantic-models" - private static let maximumSemanticSegments = 8 - private static let maximumSegmentCharacters = 500 + // Segment length tracks the training corpus, whose records are single + // sentences (zh max 33 characters, en max 138). Feeding a whole multi- + // sentence paste as one blob puts the classifiers far out of distribution + // and dilutes bag-of-words confidence below their routing thresholds. + private static let maximumSemanticSegments = 24 + private static let maximumSegmentCharacters = 180 private static let minimumSentimentConfidence = 0.65 private static let minimumSentimentMargin = 0.15 @@ -356,9 +360,6 @@ public actor ClipboardSemanticAnalyzer { } private func semanticSegments(in text: String) -> [String] { - if text.count <= Self.maximumSegmentCharacters { - return [text] - } let tokenizer = NLTokenizer(unit: .sentence) tokenizer.string = text var segments: [String] = [] diff --git a/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift b/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift index 41b4229..c7a824f 100644 --- a/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift +++ b/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift @@ -9,9 +9,27 @@ import Combine import Foundation public enum ClipboardSkillSemanticRanker { - private static let longTextCharacterThreshold = 360 + /// Calibrated against Latin script. `effectiveLength` scales dense scripts + /// up to the same information density before comparing. + private static let longTextLengthThreshold = 360.0 + private static let listLineLengthThreshold = 48.0 + private static let denseScriptCharacterWeight = 2.25 + /// A bare link or phone paste keeps at most a short label ("详情:", + /// "Contact:"). Anything longer means the entity is embedded in a real + /// message, which must keep its own skills. + private static let entityResidualLengthLimit = 12 private static let languageConfidenceThreshold = 0.75 private static let maximumReplyRecommendations = 2 + /// Characters permitted in a URL, used to carve the link out of text with no + /// whitespace around it (`详见https://example.com`). Deliberately spelled out + /// in ASCII: `CharacterSet.alphanumerics` also matches CJK ideographs, which + /// would swallow the surrounding message. + private static let urlCharacters = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789" + + "-._~:/?#[]@!$&'()*+,;=%" + ) public static func ranked( skills: [AIClipboardSkill], @@ -91,18 +109,28 @@ public enum ClipboardSkillSemanticRanker { scores[id, default: 0] += value } + // A bare link or phone paste has no other intent to serve, so it stays + // exclusive. One embedded in a message must not suppress the skills the + // message itself earns — scoring continues at a weight that still ranks + // the direct action high without outranking a strong message intent. if let webURL = analysis.singleWebURL { - boost(AIClipboardSkillCatalog.openLinkID, 320) + let isBarePaste = isWebLinkDominant(sourceText, url: webURL) + boost(AIClipboardSkillCatalog.openLinkID, isBarePaste ? 320 : 200) if webURL.scheme?.lowercased() == "https" { - boost(AIClipboardSkillCatalog.summarizeWebPageID, 310) + boost(AIClipboardSkillCatalog.summarizeWebPageID, isBarePaste ? 310 : 150) + } + if isBarePaste { + return scores } - return scores } if analysis.singlePhoneNumber != nil { - boost(AIClipboardSkillCatalog.callPhoneID, 320) - boost(AIClipboardSkillCatalog.createContactID, 310) - return scores + let isBarePaste = isPhoneNumberDominant(sourceText) + boost(AIClipboardSkillCatalog.callPhoneID, isBarePaste ? 320 : 200) + boost(AIClipboardSkillCatalog.createContactID, isBarePaste ? 310 : 150) + if isBarePaste { + return scores + } } if isLanguageMismatch( @@ -163,7 +191,7 @@ public enum ClipboardSkillSemanticRanker { boost(AIClipboardSkillCatalog.summarizeID, 45) } - if sourceText.count >= longTextCharacterThreshold { + if effectiveLength(sourceText) >= longTextLengthThreshold { boost(AIClipboardSkillCatalog.summarizeID, 145) boost(AIClipboardSkillCatalog.saveToNotesID, 85) } @@ -174,7 +202,7 @@ public enum ClipboardSkillSemanticRanker { || isAdvisoryComplaint(analysis.complaint) if analysis.replyableMessage.isDetected, !hasSpecializedReplyIntent, - sourceText.count < longTextCharacterThreshold, + effectiveLength(sourceText) < longTextLengthThreshold, !isListLike(sourceText) { boost(AIClipboardSkillCatalog.replyID, 160) if analysis.sentiment != .negative, @@ -235,8 +263,71 @@ public enum ClipboardSkillSemanticRanker { if markedCount * 2 >= lines.count { return true } - let averageLength = lines.reduce(0) { $0 + $1.count } / lines.count - return lines.count >= 3 && averageLength <= 48 + let averageLength = lines.reduce(0.0) { + $0 + effectiveLength($1) + } / Double(lines.count) + return lines.count >= 3 && averageLength <= listLineLengthThreshold + } + + /// CJK characters carry roughly twice the information of a Latin character, + /// so a raw `count` makes every length threshold fire about twice too late + /// in Chinese and too early in English. Weight dense scripts instead. + private static func effectiveLength(_ text: String) -> Double { + text.reduce(into: 0.0) { total, character in + total += isDenseScriptCharacter(character) ? denseScriptCharacterWeight : 1 + } + } + + private static func isDenseScriptCharacter(_ character: Character) -> Bool { + guard let scalar = character.unicodeScalars.first else { return false } + switch scalar.value { + case 0x3040...0x30FF, // kana + 0x3400...0x4DBF, // CJK unified ideographs extension A + 0x4E00...0x9FFF, // CJK unified ideographs + 0xAC00...0xD7AF, // hangul syllables + 0xF900...0xFAFF, // CJK compatibility ideographs + 0x20000...0x2FA1F: // CJK unified ideographs extensions B+ + return true + default: + return false + } + } + + private static func isWebLinkDominant(_ text: String, url: URL) -> Bool { + guard let host = url.host, !host.isEmpty else { return false } + guard let hostRange = text.range(of: host, options: [.caseInsensitive]) else { + return significantCharacterCount(text) <= entityResidualLengthLimit + } + var start = hostRange.lowerBound + while start > text.startIndex { + let previous = text.index(before: start) + guard isURLCharacter(text[previous]) else { break } + start = previous + } + var end = hostRange.upperBound + while end < text.endIndex, isURLCharacter(text[end]) { + end = text.index(after: end) + } + var residual = text + residual.removeSubrange(start.. Bool { + text.reduce(into: 0) { count, character in + if character.isLetter { count += 1 } + } <= entityResidualLengthLimit + } + + private static func significantCharacterCount(_ text: String) -> Int { + text.reduce(into: 0) { count, character in + if character.isLetter || character.isNumber { count += 1 } + } + } + + private static func isURLCharacter(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy { urlCharacters.contains($0) } } private static func isMarkedListLine(_ line: String) -> Bool { diff --git a/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift b/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift index a076db6..b2da517 100644 --- a/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift +++ b/OSGKeyboardShared/Services/LocalASRModelDownloadClient.swift @@ -23,22 +23,27 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload private let destinationURL: URL private let onProgress: @Sendable (LocalASRDownloadProgressUpdate) -> Void private let maxRetries: Int - private lazy var delegateSession: URLSession = { - let config = URLSessionConfiguration.default - // Big weight files over flaky links: allow long total transfers but - // fail (and retry) a stalled connection that goes quiet for a while. - config.timeoutIntervalForRequest = 90 - config.timeoutIntervalForResource = 24 * 60 * 60 - config.waitsForConnectivity = true - return URLSession(configuration: config, delegate: self, delegateQueue: nil) - }() + private var delegateSession: URLSession! + // Every field below is reachable from BOTH the caller's thread + // (`download` / `pause` / `resumeFromPause` / `cancel`) and URLSession's + // delegate queue (`delegateQueue: nil` = a private serial background + // queue), so all of them are guarded by `lock`. That is what makes the + // `@unchecked Sendable` above true rather than aspirational: an unguarded + // check-then-act on `finished` let two threads each resume + // `completionContinuation`, and a checked continuation resumed twice is a + // hard crash, not a recoverable error. + private let lock = NSLock() private var remoteURL: URL? private var task: URLSessionDownloadTask? private var completionContinuation: CheckedContinuation? private var isPausing = false private var finished = false private var retryCount = 0 + /// Set by `cancel()` before it invalidates the session. `startTask` refuses + /// to build a task once this is set: `URLSession.downloadTask` on an + /// invalidated session raises an ObjC exception, which Swift cannot catch. + private var sessionInvalidated = false init( destinationURL: URL, @@ -49,57 +54,145 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload self.maxRetries = maxRetries self.onProgress = onProgress super.init() + let config = URLSessionConfiguration.default + // Big weight files over flaky links: allow long total transfers but + // fail (and retry) a stalled connection that goes quiet for a while. + config.timeoutIntervalForRequest = 90 + config.timeoutIntervalForResource = 24 * 60 * 60 + config.waitsForConnectivity = true + // Built here rather than in a `lazy var`: lazy initialization is not + // atomic, and this session is first touched from whichever thread wins + // the `startTask` / `cancel` race. + delegateSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + /// Atomically takes ownership of the pending completion continuation and + /// marks the download finished. Returns `nil` when another thread already + /// claimed it — the single guarantee that `download(from:)` is resumed + /// exactly once, no matter how cancel / completion / failure interleave. + private func claimCompletion() -> CheckedContinuation? { + lock.withLock { () -> CheckedContinuation? in + guard !finished else { return nil } + finished = true + let pending = completionContinuation + completionContinuation = nil + return pending + } } /// Runs until the archive is fully written to `destinationURL` (survives pause/resume). public func download(from remoteURL: URL) async throws { - self.remoteURL = remoteURL - retryCount = 0 + // A controller is single-use — `LocalASRModelDownloadClient.makeController` + // builds a fresh one per download. `finished` is deliberately NOT reset + // here: doing so would resurrect a controller already settled by + // `cancel()`, and `startTask` would then hand work to a session that + // `cancel()` had invalidated (an uncatchable ObjC exception). + lock.withLock { + self.remoteURL = remoteURL + retryCount = 0 + } + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - completionContinuation = continuation + // `cancel()` can land between the setup above and here; parking a + // continuation nobody will ever resume would hang the caller. + let alreadyCancelled = lock.withLock { () -> Bool in + guard !finished else { return true } + completionContinuation = continuation + return false + } + guard !alreadyCancelled else { + continuation.resume(throwing: CancellationError()) + return + } startTask(resumeData: nil) } } public func pause() async throws -> Data { - guard task != nil, !finished else { + let pending = lock.withLock { () -> URLSessionDownloadTask? in + guard let running = task, !finished, !isPausing else { return nil } + isPausing = true + return running + } + guard let pending else { throw LocalASRModelManagerError.downloadFailed("No active download to pause.") } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - isPausing = true - task?.cancel(byProducingResumeData: { [weak self] data in - guard let self else { return } - self.isPausing = false - if let data { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: LocalASRModelManagerError.downloadFailed("Pause failed.")) + pending.cancel(byProducingResumeData: { [weak self] data in + guard let self else { + continuation.resume( + throwing: LocalASRModelManagerError.downloadFailed("Pause failed.") + ) + return } + if let data { + // `isPausing` deliberately stays set until `resumeFromPause` + // / `cancel` clears it. Cancelling for resume data also + // fires `didCompleteWithError`, and that callback can land + // either side of this one — clearing the flag here (as this + // used to) let the cancellation be misread as a permanent + // failure, tearing down a download the user only paused. + continuation.resume(returning: data) + return + } + // No resume data: the task is dead and cannot be continued, and + // the `didCompleteWithError` that would normally report it was + // suppressed by `isPausing`. Settle `download(from:)` here so + // it cannot hang waiting for a callback that already passed. + self.lock.withLock { self.isPausing = false } + let failure = LocalASRModelManagerError.downloadFailed("Pause failed.") + self.claimCompletion()?.resume(throwing: failure) + continuation.resume(throwing: failure) }) } } /// Continues a paused download; `download(from:)` must still be awaiting. public func resumeFromPause(_ resumeData: Data) { - finished = false + // Only a genuinely paused download may continue. `finished` is NOT + // reset: a completed or cancelled download must never be revived, and + // pausing no longer sets it in the first place. + let canResume = lock.withLock { () -> Bool in + guard isPausing, !finished else { return false } + isPausing = false + return true + } + guard canResume else { return } startTask(resumeData: resumeData) } public func cancel() { - finished = true - task?.cancel() - completionContinuation?.resume(throwing: CancellationError()) - completionContinuation = nil + let pending = lock.withLock { () -> URLSessionDownloadTask? in + isPausing = false + // Flagged under the same lock `startTask` takes, so the two are + // totally ordered: either the task is built first (and cancelled + // just below), or `startTask` sees this and builds nothing. + sessionInvalidated = true + return task + } + // Claim before cancelling the task: the cancellation then arrives at + // `didCompleteWithError` already settled, so it cannot re-report the + // download as a generic network failure. + claimCompletion()?.resume(throwing: CancellationError()) + pending?.cancel() delegateSession.invalidateAndCancel() } private func startTask(resumeData: Data?) { - if let resumeData { - task = delegateSession.downloadTask(withResumeData: resumeData) - } else if let remoteURL { - task = delegateSession.downloadTask(with: remoteURL) + let started = lock.withLock { () -> URLSessionDownloadTask? in + guard !sessionInvalidated else { return nil } + let created: URLSessionDownloadTask? + if let resumeData { + created = delegateSession.downloadTask(withResumeData: resumeData) + } else if let remoteURL { + created = delegateSession.downloadTask(with: remoteURL) + } else { + created = nil + } + task = created + return created } - task?.resume() + started?.resume() } // MARK: - URLSessionDownloadDelegate @@ -124,48 +217,56 @@ public final class LocalASRModelDownloadController: NSObject, URLSessionDownload downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL ) { - guard !finished else { return } - finished = true + // Claim first: a cancelled download must not have its archive moved + // into place, and claiming is what stops the `didCompleteWithError` + // that follows every finished download from resuming us a second time. + guard let pending = claimCompletion() else { return } do { let fm = FileManager.default if fm.fileExists(atPath: destinationURL.path) { try fm.removeItem(at: destinationURL) } try fm.moveItem(at: location, to: destinationURL) - completionContinuation?.resume() + pending.resume() } catch { - completionContinuation?.resume( + pending.resume( throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription) ) } - completionContinuation = nil session.finishTasksAndInvalidate() } public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard !finished else { return } - if isPausing { return } + // `isPausing` means the cancellation below is the one *we* asked for in + // `pause()`; the download is not over and must keep its continuation. + let (suppressed, shouldRetry, delay) = lock.withLock { () -> (Bool, Bool, Double) in + let suppressed = finished || isPausing + guard let error, !suppressed, Self.isRetryable(error), retryCount < maxRetries else { + return (suppressed, false, 0) + } + retryCount += 1 + return (suppressed, true, Self.backoffSeconds(attempt: retryCount)) + } + + if suppressed { return } guard let error else { return } // Transient network drop: resume from where we stopped (if the server // handed back resume data) after a short exponential backoff, up to a cap. - if Self.isRetryable(error), retryCount < maxRetries { - retryCount += 1 + if shouldRetry { let resumeData = (error as NSError) .userInfo[NSURLSessionDownloadTaskResumeData] as? Data - let delay = Self.backoffSeconds(attempt: retryCount) DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, !self.finished, !self.isPausing else { return } + guard let self else { return } + let stillActive = self.lock.withLock { !self.finished && !self.isPausing } + guard stillActive else { return } self.startTask(resumeData: resumeData) } return } - finished = true - completionContinuation?.resume( - throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription) - ) - completionContinuation = nil + guard let pending = claimCompletion() else { return } + pending.resume(throwing: LocalASRModelManagerError.downloadFailed(error.localizedDescription)) session.finishTasksAndInvalidate() } diff --git a/OSGKeyboardShared/Services/Tip/TipProduct.swift b/OSGKeyboardShared/Services/Tip/TipProduct.swift deleted file mode 100644 index 8477fc5..0000000 --- a/OSGKeyboardShared/Services/Tip/TipProduct.swift +++ /dev/null @@ -1,17 +0,0 @@ -// TipProduct.swift -// OSGKeyboard · Shared -// -// StoreKit product identifiers for optional voluntary tips. -// Tips are consumable IAP — they do not unlock features. - -import Foundation - -public enum TipProduct { - /// ¥28 voluntary support tip (Consumable). Must match App Store Connect Product ID. - public static let supportID = "ByRockyACoffee" - - public static var allProductIDs: [String] { [supportID] } - - /// UserDefaults key for optional UX: how many times the user tipped (not entitlements). - public static let supportCountDefaultsKey = "tipSupportPurchaseCount" -} diff --git a/OSGKeyboardShared/Typing/EnglishLexicon.swift b/OSGKeyboardShared/Typing/EnglishLexicon.swift index 938361a..2bd879d 100644 --- a/OSGKeyboardShared/Typing/EnglishLexicon.swift +++ b/OSGKeyboardShared/Typing/EnglishLexicon.swift @@ -204,6 +204,15 @@ public final class EnglishLexicon: @unchecked Sendable { guard let group = lookupBigramGroup(prevIndex: prevIndex, header: header, buf: buf) else { return [] } + // `firstNext` / `nextCount` come straight off the mapped file; a + // group that runs past the next-word table is corrupt, and reading + // it would be an out-of-bounds `loadUnaligned`. + guard group.firstNext >= 0, + group.nextCount >= 0, + group.firstNext + group.nextCount <= header.bigramNextCapacity + else { + return [] + } let count = min(limit, group.nextCount) var words: [String] = [] words.reserveCapacity(count) @@ -231,6 +240,11 @@ public final class EnglishLexicon: @unchecked Sendable { var bigramNextOffset: Int var fileSize: Int + /// How many 2-byte entries fit between `bigramNextOffset` and EOF. The + /// header carries no length for this table, so reads are bounded + /// against the file tail rather than a declared count. + var bigramNextCapacity: Int { (fileSize - bigramNextOffset) / 2 } + static let magic = "OSGENG01" static let version = 1 static let headerSize = 64 @@ -260,7 +274,11 @@ public final class EnglishLexicon: @unchecked Sendable { region(freqRankOffset, unigramCount * 2, in: fileSize), region(initialOffset, initialCount * 4, in: fileSize), region(bigramIndexOffset, bigramGroupCount * 8, in: fileSize), - region(stringPoolOffset, stringPoolSize, in: fileSize) + region(stringPoolOffset, stringPoolSize, in: fileSize), + // The next-word table has no length field, so only its + // base can be validated here; per-group reads are bounded + // against `bigramNextCapacity` at the call site. + region(bigramNextOffset, 0, in: fileSize) else { return nil } diff --git a/OSGKeyboardShared/Typing/EnglishQWERTYProximity.swift b/OSGKeyboardShared/Typing/EnglishQWERTYProximity.swift index 63b2a61..44a121e 100644 --- a/OSGKeyboardShared/Typing/EnglishQWERTYProximity.swift +++ b/OSGKeyboardShared/Typing/EnglishQWERTYProximity.swift @@ -159,6 +159,12 @@ public enum EnglishQWERTYProximity: Sendable { ) -> Int { let aCount = source.count let bCount = target.count + // `1...count` traps on an empty operand. Current callers filter empties + // out via the `delta <= 2` gate, but this is a public entry point — an + // empty word has no proximity cost, so fall back to pure insert/delete. + guard aCount > 0, bCount > 0 else { + return max(aCount, bCount) * insDelCost + } var previous = Array(0...bCount).map { $0 * insDelCost } var older = previous for i in 1...aCount { diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 960c2ce..e8d1bba 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -136,7 +136,6 @@ "ai.responseLength.medium" = "Medium"; "ai.responseLength.detailed" = "Detailed"; - /* v0.3.0: Detected app context labels */ "appContext.code" = "Code"; "appContext.email" = "Email"; @@ -345,6 +344,7 @@ "mac.hint.hold.leftOption" = "Hold left Option (⌥) to dictate"; "mac.hint.hold.eitherOption" = "Hold either Option (⌥) to dictate"; "mac.hint.holdOption" = "Hold Option to dictate"; +"mac.hint.holdOption.compact" = "Hold Option"; "mac.settings.qwen3Model" = "Qwen3 model folder"; "mac.settings.qwen3ModelDesc" = "Folder with config.json, model.safetensors, vocab.json, and merges.txt."; "mac.settings.qwen3Browse" = "Choose folder…"; @@ -384,9 +384,12 @@ "mac.onboarding.engine.subtitle" = "Start locally for privacy, or use your own cloud API for provider-based recognition and polish."; "mac.onboarding.engine.localDesc" = "Private on-device recognition with Sherpa models. Falls back to Apple Speech until a model is installed."; "mac.onboarding.engine.cloudDesc" = "Use your API key for cloud speech recognition and AI polishing."; -"mac.onboarding.cloud.title" = "Configure API access"; -"mac.onboarding.cloud.subtitle" = "Pick a provider and add your key. This step is optional, so you can finish now and fill it later in Settings."; +"mac.onboarding.cloud.title" = "Configure speech recognition"; +"mac.onboarding.cloud.subtitle" = "Pick a cloud speech provider and add your key. This step is optional, so you can finish now and fill it later in Settings."; "mac.onboarding.cloud.skipHint" = "You can leave these blank and finish setup; cloud mode will ask for a key before use."; +"mac.onboarding.polish.title" = "Configure polishing"; +"mac.onboarding.polish.subtitle" = "Pick the model provider used for polishing, translation and AI answers. Optional — you can fill it in later in Settings."; +"mac.onboarding.polish.skipHint" = "You can leave these blank and finish setup; polishing will ask for a key before use."; "mac.onboarding.model.title" = "Prepare local recognition"; "mac.onboarding.model.subtitle" = "Download the basic Sherpa runtime and default model now, or skip and continue with Apple Speech fallback."; "mac.onboarding.model.download" = "Download Default Model"; @@ -459,17 +462,56 @@ "settings.appLanguage.english" = "English"; "settings.appLanguage.chinese" = "Chinese"; -/* Voluntary support tip (Consumable IAP — no feature unlock) */ -"tip.title" = "Support the Developer"; -"tip.body" = "OSGKeyboard is completely free with every feature unlocked. If you find it useful, you can leave an optional tip — it does not unlock anything extra."; -"tip.button" = "Tip %@"; -"tip.buttonFallback" = "Send a Tip"; -"tip.consumableNotice" = "Consumable in-app purchase. Tips cannot be restored (Apple policy)."; -"tip.thankYou.title" = "Thank you!"; -"tip.thankYou.message" = "Your support helps keep OSGKeyboard free and open for everyone."; -"tip.thankYou.past" = "You've supported us %lld time(s). Thank you!"; -"tip.error.title" = "Purchase Failed"; -"tip.error.productUnavailable" = "The tip product is unavailable right now. Try again later."; -"tip.error.pending" = "Your purchase is pending approval."; -"tip.error.unknown" = "The purchase could not be completed."; -"tip.alert.dismiss" = "OK"; + +/* Account — Mac account page (Sign in with Apple, credits, invitation link). + The account.* / account.referral.* keys below mirror the iOS app bundle so + the shared AccountSessionCoordinator error keys resolve on macOS too. */ +"mac.section.account" = "Account"; +"mac.account.restoring" = "Restoring your session…"; +"mac.account.signedOut.title" = "Not signed in"; +"mac.account.signedOut.body" = "Sign in with Apple to use your credit balance on this Mac and to get your invitation link."; +"mac.account.signedOut.footnote" = "Credits are bought on iPhone or on the web and shared across all your devices."; +"mac.account.signIn.loading" = "Signing in…"; +"mac.account.profile.unnamed" = "Apple Account"; +"mac.account.profile.accountID" = "Account %@"; +"mac.account.signOut" = "Sign Out"; +"mac.account.credits.title" = "Credits"; +"mac.account.credits.balance" = "Balance"; +"mac.account.credits.used" = "Used"; +"mac.account.credits.loading" = "Loading balance…"; +"mac.account.credits.refresh" = "Refresh"; +"mac.account.credits.purchaseNote" = "Purchases are not available on macOS. Buy credits on iPhone or on the web — the balance is shared."; +"mac.account.invite.title" = "Invitation"; +"mac.account.invite.link" = "Your invitation link"; +"mac.account.invite.copy" = "Copy"; +"mac.account.invite.copied" = "Copied"; +"mac.account.invite.loading" = "Loading your invitation link…"; +"mac.account.invite.retry" = "Retry"; +"mac.account.invite.rewarded" = "Rewarded invites"; +"mac.account.invite.pending" = "Pending invites"; +"mac.account.invite.footnote" = "Both of you get credits once an invited friend qualifies."; +"mac.account.delete.title" = "Delete Account"; +"mac.account.delete.body" = "Deleting removes your account and any remaining credits. This cannot be undone."; +"mac.account.delete.action" = "Delete Account…"; +"mac.account.delete.confirmBody" = "Confirm with Apple to permanently delete your account and remaining credits."; +"mac.account.delete.cancel" = "Cancel"; +"mac.account.error.title" = "Account"; +"mac.account.error.dismiss" = "OK"; +"account.error.appleAuthorization" = "Apple authorization could not be completed."; +"account.error.appleReauthenticationRequired" = "Sign in with Apple again to keep your account protected."; +"account.error.delete" = "Could not delete the account."; +"account.error.load" = "Could not load account details."; +"account.error.managedGateway" = "Could not prepare the managed service."; +"account.error.redeemReferral" = "The invitation is saved, but could not be redeemed yet."; +"account.error.restore" = "Could not restore your account session."; +"account.error.sessionExpired" = "Your session expired. Sign in again to continue."; +"account.error.signIn" = "Could not sign in. Please try again."; +"account.error.signOut" = "Could not sign out. Please try again."; +"account.error.unavailable" = "Account services are not connected in this build."; +"account.error.updateProfile" = "Could not update your profile."; +"account.referral.error.conflict" = "Your invitation link is temporarily unavailable."; +"account.referral.error.load" = "Couldn’t load your invitation link."; +"account.referral.error.network" = "Check your connection and try again."; +"account.referral.error.notFound" = "Your invitation link isn’t available yet."; +"account.referral.error.session" = "Sign in again to load your invitation link."; +"account.referral.error.unavailable" = "The invitation service is temporarily unavailable."; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index a9986ec..58c434d 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -344,6 +344,7 @@ "mac.hint.hold.leftOption" = "长按左 Option(⌥)开始听写"; "mac.hint.hold.eitherOption" = "长按任一 Option(⌥)开始听写"; "mac.hint.holdOption" = "长按 Option 开始听写"; +"mac.hint.holdOption.compact" = "按住 Option"; "mac.settings.qwen3Model" = "Qwen3 模型目录"; "mac.settings.qwen3ModelDesc" = "需包含 config.json、model.safetensors、vocab.json 与 merges.txt。"; "mac.settings.qwen3Browse" = "选择文件夹…"; @@ -383,9 +384,12 @@ "mac.onboarding.engine.subtitle" = "默认可用本地识别以保护隐私,也可以配置自己的云端 API。"; "mac.onboarding.engine.localDesc" = "使用 Sherpa 本地模型进行私密转写;模型未安装前会回退到 Apple 语音识别。"; "mac.onboarding.engine.cloudDesc" = "使用你的 API Key 进行云端语音识别和 AI 润色。"; -"mac.onboarding.cloud.title" = "配置 API 访问"; -"mac.onboarding.cloud.subtitle" = "选择服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。"; +"mac.onboarding.cloud.title" = "配置语音识别服务"; +"mac.onboarding.cloud.subtitle" = "选择云端语音识别服务商并填写密钥。此步骤可跳过,之后可在设置中补齐。"; "mac.onboarding.cloud.skipHint" = "可以先留空并完成引导;云端模式使用前会提示需要 API Key。"; +"mac.onboarding.polish.title" = "配置润色服务"; +"mac.onboarding.polish.subtitle" = "选择用于润色、翻译和 AI 问答的大模型服务商。此步骤可跳过,之后可在设置中补齐。"; +"mac.onboarding.polish.skipHint" = "可以先留空并完成引导;使用润色前会提示需要 API Key。"; "mac.onboarding.model.title" = "准备本地识别"; "mac.onboarding.model.subtitle" = "现在下载基础 Sherpa runtime 和默认模型,或先跳过并使用 Apple Speech 兜底。"; "mac.onboarding.model.download" = "下载默认模型"; @@ -458,17 +462,56 @@ "settings.appLanguage.english" = "英文"; "settings.appLanguage.chinese" = "中文"; -/* 自愿打赏(消耗型 IAP — 不解锁功能) */ -"tip.title" = "支持开发者"; -"tip.body" = "OSGKeyboard 全功能免费、无任何付费墙。若觉得好用,可以自愿打赏一杯咖啡——不会解锁额外功能。"; -"tip.button" = "打赏 %@"; -"tip.buttonFallback" = "打赏支持"; -"tip.consumableNotice" = "消耗型应用内购买,无法恢复购买(Apple 规则)。"; -"tip.thankYou.title" = "感谢支持!"; -"tip.thankYou.message" = "你的支持帮助我们继续免费、开源地维护 OSGKeyboard。"; -"tip.thankYou.past" = "你已支持 %lld 次,感谢!"; -"tip.error.title" = "购买失败"; -"tip.error.productUnavailable" = "暂时无法加载打赏商品,请稍后再试。"; -"tip.error.pending" = "购买待审批,请稍后查看。"; -"tip.error.unknown" = "无法完成购买。"; -"tip.alert.dismiss" = "好"; + +/* Account — Mac account page (Sign in with Apple, credits, invitation link). + The account.* / account.referral.* keys below mirror the iOS app bundle so + the shared AccountSessionCoordinator error keys resolve on macOS too. */ +"mac.section.account" = "账户"; +"mac.account.restoring" = "正在恢复登录状态…"; +"mac.account.signedOut.title" = "尚未登录"; +"mac.account.signedOut.body" = "通过 Apple 登录,即可在这台 Mac 上使用积分余额并获取你的邀请链接。"; +"mac.account.signedOut.footnote" = "积分在 iPhone 或网页购买,所有设备共用同一余额。"; +"mac.account.signIn.loading" = "正在登录…"; +"mac.account.profile.unnamed" = "Apple 账户"; +"mac.account.profile.accountID" = "账户 %@"; +"mac.account.signOut" = "退出登录"; +"mac.account.credits.title" = "积分"; +"mac.account.credits.balance" = "余额"; +"mac.account.credits.used" = "已使用"; +"mac.account.credits.loading" = "正在加载余额…"; +"mac.account.credits.refresh" = "刷新"; +"mac.account.credits.purchaseNote" = "macOS 端不支持购买。请在 iPhone 或网页购买积分,余额通用。"; +"mac.account.invite.title" = "邀请"; +"mac.account.invite.link" = "你的邀请链接"; +"mac.account.invite.copy" = "复制"; +"mac.account.invite.copied" = "已复制"; +"mac.account.invite.loading" = "正在加载邀请链接…"; +"mac.account.invite.retry" = "重试"; +"mac.account.invite.rewarded" = "已奖励邀请"; +"mac.account.invite.pending" = "待生效邀请"; +"mac.account.invite.footnote" = "受邀好友达成条件后,双方都可获得积分。"; +"mac.account.delete.title" = "注销账号"; +"mac.account.delete.body" = "注销会删除你的账号及剩余积分,且无法撤销。"; +"mac.account.delete.action" = "注销账号…"; +"mac.account.delete.confirmBody" = "请通过 Apple 确认,以永久删除账号及剩余积分。"; +"mac.account.delete.cancel" = "取消"; +"mac.account.error.title" = "账户"; +"mac.account.error.dismiss" = "好"; +"account.error.appleAuthorization" = "未能完成 Apple 授权。"; +"account.error.appleReauthenticationRequired" = "为继续保护账号,请重新通过 Apple 登录。"; +"account.error.delete" = "无法注销账号。"; +"account.error.load" = "无法加载账号详情。"; +"account.error.managedGateway" = "无法准备托管服务。"; +"account.error.redeemReferral" = "邀请已暂存,但暂时无法兑换。"; +"account.error.restore" = "无法恢复账号会话。"; +"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。"; +"account.error.signIn" = "登录失败,请重试。"; +"account.error.signOut" = "退出失败,请重试。"; +"account.error.unavailable" = "当前构建尚未接入账号服务。"; +"account.error.updateProfile" = "无法更新个人资料。"; +"account.referral.error.conflict" = "邀请链接暂时不可用。"; +"account.referral.error.load" = "无法加载邀请链接。"; +"account.referral.error.network" = "请检查网络连接后重试。"; +"account.referral.error.notFound" = "邀请链接暂不可用。"; +"account.referral.error.session" = "请重新登录后加载邀请链接。"; +"account.referral.error.unavailable" = "邀请服务暂时不可用。"; diff --git a/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift b/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift index 0629ef0..2fcc0bf 100644 --- a/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift +++ b/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift @@ -126,6 +126,21 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase { XCTAssertFalse(acknowledgment.replyableMessage.isDetected) } + /// The classifiers are trained on single sentences. A multi-sentence paste + /// analyzed as one blob dilutes bag-of-words confidence below the routing + /// threshold, so the intent must be scored per sentence instead. + func testIntentIsFoundInsideAMultiSentencePaste() async { + let analysis = await ClipboardSemanticAnalyzer().analyze( + """ + 各位好,这是本周的进度同步。设计稿已经全部定稿,开发那边也开始联调了。\ + 麻烦你今天下班前把新版报价单发我。另外测试环境这两天可能会有波动,\ + 大家注意一下。 + """ + ) + + XCTAssertTrue(analysis.task.isDetected) + } + func testPersonalPlanDoesNotBecomeAutomaticTask() async { let analysis = await ClipboardSemanticAnalyzer().analyze( "私人备忘:我准备周五自己整理完这份报告。" diff --git a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift index e8a9fd6..67f15a2 100644 --- a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift +++ b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift @@ -369,6 +369,136 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { ) } + func testLinkInsideAMessageKeepsTheMessageSkills() { + let recommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: "会议链接 https://zoom.us/j/123 周三下午三点开始,能来吗?", + analysis: analysis( + hasDate: true, + urls: [URL(string: "https://zoom.us/j/123")!], + question: detected(), + invitation: detected() + ), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.extractEventsID, + AIClipboardSkillCatalog.acceptInvitationID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.openLinkID + ] + ) + } + + func testLabelledLinkPasteStaysExclusive() { + let recommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: "详情见 https://example.com/article", + analysis: analysis(urls: [URL(string: "https://example.com/article")!]), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.openLinkID, + AIClipboardSkillCatalog.summarizeWebPageID, + AIClipboardSkillCatalog.replyID + ] + ) + } + + func testPhoneNumberInsideAMessageKeepsTheMessageSkills() { + let recommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: "王经理说这个订单有问题,你直接打 400-666-8800 找售后处理一下。", + analysis: analysis( + phoneNumbers: [ClipboardTextLabel(sourceText: "400-666-8800")], + task: detected() + ), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.callPhoneID, + AIClipboardSkillCatalog.extractTodosID, + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.acceptTaskID, + AIClipboardSkillCatalog.replyID + ] + ) + } + + /// 180 Chinese characters carry about as much text as 400 Latin ones, so the + /// summary threshold must fire well below the raw 360-character mark. + func testChineseTextReachesTheSummaryThresholdBelowTheLatinCharacterCount() { + let text = String( + repeating: "这是一段需要归纳整理的中文长文内容。", + count: 10 + ) + let recommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: text, + analysis: analysis(), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + XCTAssertLessThan(text.count, 360) + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.saveToNotesID, + AIClipboardSkillCatalog.replyID + ] + ) + } + + func testHardWrappedChineseProseIsNotTreatedAsAList() { + let prose = [ + "我们这次的目标是在下个季度之前完成整套流程的重构工作", + "同时还要保证现有的客户不会受到任何影响并保持稳定", + "最后需要在月底之前把完整的测试报告提交给管理层评审" + ].joined(separator: "\n") + + let proseRecommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: prose, + analysis: analysis(), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + let listRecommendations = ClipboardSkillSemanticRanker.recommended( + skills: AIClipboardSkillCatalog.catalog, + sourceText: "牛奶\n鸡蛋\n面包", + analysis: analysis(), + uiLanguage: .chinese, + limit: 5, + preferredLanguages: ["zh-Hans"] + ).map(\.id) + + XCTAssertEqual(proseRecommendations, [AIClipboardSkillCatalog.replyID]) + XCTAssertTrue( + listRecommendations.contains(AIClipboardSkillCatalog.organizeListID) + ) + } + private func rank( text: String, analysis: ClipboardSemanticAnalysis diff --git a/OSGKeyboardTests/FlowLevelStoreTests.swift b/OSGKeyboardTests/FlowLevelStoreTests.swift new file mode 100644 index 0000000..36b3698 --- /dev/null +++ b/OSGKeyboardTests/FlowLevelStoreTests.swift @@ -0,0 +1,107 @@ +// FlowLevelStoreTests.swift +// OSGKeyboardTests +// +// Regression cover for the waveform bar arithmetic. `calculateLevels` runs on +// the AVAudioEngine realtime thread, where a reversed `start.. AVAudioPCMBuffer { + let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 48_000, + channels: 1, + interleaved: false + )! + // frameCapacity must be > 0 even when we want a zero-length buffer. + let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(max(frameLength, 1)) + )! + buffer.frameLength = AVAudioFrameCount(frameLength) + if let channel = buffer.floatChannelData?[0] { + for index in 0.. 0, "leading bars should carry the signal") + for index in 10.. 0 }, + "frameLength=\(frameLength) should light every bar" + ) + } + } + + /// The instance path the audio tap actually calls. + func testUpdateFromShortBufferKeepsSnapshotUsable() { + let store = FlowLevelStore(barCount: barCount) + store.update(from: makeBuffer(frameLength: 3), barCount: barCount) + XCTAssertEqual(store.snapshot().count, barCount) + } +} diff --git a/OSGKeyboardTests/OfficialSkillCatalogTests.swift b/OSGKeyboardTests/OfficialSkillCatalogTests.swift index 06f9358..0bc3c79 100644 --- a/OSGKeyboardTests/OfficialSkillCatalogTests.swift +++ b/OSGKeyboardTests/OfficialSkillCatalogTests.swift @@ -128,17 +128,29 @@ final class OfficialSkillCatalogTests: XCTestCase { let migrated = store.agentSkillLayout + // What this test is named for: the v2 → current ladder installs the + // cached official transform skill, exactly once. XCTAssertEqual( - migrated.enabledIDs, - [ - AIClipboardSkillCatalog.replyID, - AIClipboardSkillCatalog.openLinkID, - AIClipboardSkillCatalog.summarizeWebPageID, - AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID, - "official.rewrite" - ] + migrated.enabledIDs.filter { $0 == "official.rewrite" }.count, + 1, + "cached official transform skill must be installed exactly once" ) + // A layout the user already had must survive the migration intact. + XCTAssertEqual(migrated.enabledIDs.first, AIClipboardSkillCatalog.replyID) + XCTAssertEqual( + Set(migrated.enabledIDs).count, + migrated.enabledIDs.count, + "migration must not duplicate any skill" + ) + + // The "once" half: re-reading adds nothing further now that the stored + // migration version has caught up. + // + // Deliberately NOT asserting the full built-in list here. Which + // built-ins each ladder step adds is the ladder's own business (covered + // by the version-specific tests above); freezing the whole list in this + // test is exactly what rotted it when a later step was appended. + XCTAssertEqual(store.agentSkillLayout.enabledIDs, migrated.enabledIDs) } func testValidationRejectsWholeInvalidSnapshot() { diff --git a/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift b/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift index 7916458..2a7d5f0 100644 --- a/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift +++ b/OSGKeyboardTests/TestSupport/AccountTestDoubles.swift @@ -7,7 +7,7 @@ import Foundation @testable import OSGKeyboardHostSupport actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStoring { - private(set) var session: AccountSession? + private(set) var session: AccountTokenSession? private(set) var refreshTransaction: AccountRefreshTransaction? private(set) var keyState: AppAttestKeyState? private(set) var clearSessionCount = 0 @@ -16,7 +16,7 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin private var remainingSessionClearFailures: Int init( - session: AccountSession? = nil, + session: AccountTokenSession? = nil, refreshTransaction: AccountRefreshTransaction? = nil, keyState: AppAttestKeyState? = nil, sessionSaveFailures: Int = 0, @@ -29,11 +29,11 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin self.remainingSessionClearFailures = sessionClearFailures } - func loadSession() async throws -> AccountSession? { + func loadSession() async throws -> AccountTokenSession? { session } - func saveSession(_ session: AccountSession) async throws { + func saveSession(_ session: AccountTokenSession) async throws { if remainingSessionSaveFailures > 0 { remainingSessionSaveFailures -= 1 throw AccountAPIError.secureStorage @@ -107,13 +107,13 @@ actor QueueAccountTransport: AccountHTTPTransport { } actor RefreshMergingTransport: AccountHTTPTransport { - private let replacementSession: AccountSession + private let replacementSession: AccountTokenSession private let refreshError: QueueAccountTransport.Stub? private(set) var requests: [URLRequest] = [] private(set) var refreshCount = 0 init( - replacementSession: AccountSession, + replacementSession: AccountTokenSession, refreshError: QueueAccountTransport.Stub? = nil ) { self.replacementSession = replacementSession @@ -248,7 +248,7 @@ func makeHTTPResponse(request: URLRequest, statusCode: Int) -> HTTPURLResponse { )! } -func sessionEnvelopeData(_ session: AccountSession) throws -> Data { +func sessionEnvelopeData(_ session: AccountTokenSession) throws -> Data { try JSONEncoder().encode(APIDataEnvelope(data: session)) } @@ -261,8 +261,8 @@ func makeAccountSession( refreshToken: String = "refresh-old", accessExpiry: Int64 = 4_000_000_000, refreshExpiry: Int64 = 4_100_000_000 -) -> AccountSession { - AccountSession( +) -> AccountTokenSession { + AccountTokenSession( accountId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!, tokenType: "Bearer", accessToken: accessToken, diff --git a/OSGKeyboardTests/TipProductTests.swift b/OSGKeyboardTests/TipProductTests.swift deleted file mode 100644 index c034e63..0000000 --- a/OSGKeyboardTests/TipProductTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -// TipProductTests.swift -// OSGKeyboardTests -// -// Locks tip product identifiers and optional support-count persistence. - -@testable import OSGKeyboardShared -import XCTest - -final class TipProductTests: XCTestCase { - private var suiteName: String! - private var defaults: UserDefaults! - - override func setUp() { - super.setUp() - suiteName = "tip.tests.\(UUID().uuidString)" - defaults = UserDefaults(suiteName: suiteName)! - defaults.removePersistentDomain(forName: suiteName) - } - - override func tearDown() { - defaults.removePersistentDomain(forName: suiteName) - defaults = nil - super.tearDown() - } - - func testSupportProductIDIsStable() { - XCTAssertEqual(TipProduct.supportID, "ByRockyACoffee") - XCTAssertEqual(TipProduct.allProductIDs, [TipProduct.supportID]) - } - - func testSupportCountDefaultsKeyIsStable() { - XCTAssertEqual(TipProduct.supportCountDefaultsKey, "tipSupportPurchaseCount") - } - - func testSupportCountPersistsInUserDefaults() { - defaults.set(2, forKey: TipProduct.supportCountDefaultsKey) - XCTAssertEqual(defaults.integer(forKey: TipProduct.supportCountDefaultsKey), 2) - } -} diff --git a/README.en.md b/README.en.md index 44f7b69..56cfe3e 100644 --- a/README.en.md +++ b/README.en.md @@ -78,7 +78,6 @@ The iOS / iPadOS account center includes: - Server verification of purchases and a synchronized credit ledger - Short-lived, scope-limited managed-service grants for the keyboard extension -The voluntary `ByRockyACoffee` tip is separate from credit packs and does not unlock core functionality. ## History, statistics, and sync diff --git a/README.md b/README.md index cfc8427..de349b1 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,6 @@ iOS / iPadOS 账号中心支持: - 服务端核验购买结果并维护积分账本 - 键盘扩展只接收短时、限权的托管服务凭证 -自愿打赏 `ByRockyACoffee` 与积分包彼此独立,不解锁核心功能。 ## 历史、统计与同步 diff --git a/README.zh.md b/README.zh.md deleted file mode 100644 index 1984f63..0000000 --- a/README.zh.md +++ /dev/null @@ -1,3 +0,0 @@ -# OSGKeyboard - -中文说明已合并至主文档:[README.md](./README.md) diff --git a/Scripts/compose_feature_cards.py b/Scripts/compose_feature_cards.py new file mode 100644 index 0000000..72031d1 --- /dev/null +++ b/Scripts/compose_feature_cards.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Compose short, silent, seamlessly looping feature clips for in-app cards. + +Different deliverable from `compose_feature_previews.py`: no captions, no +title / end cards, no audio track, and cropped to the keyboard chrome so the +card shows the real keyboard rather than a stand-in host document. + +Each clip is built to loop — the last frame matches the first, so a card can +autoplay it forever without a visible cut. + +Reuses the raw recordings from `Scripts/record_feature_previews.sh` and the +CFR normalisation from `compose_feature_previews.py`. + +Usage: + Scripts/compose_feature_cards.py [slug ...] + +Output: docs/assets/feature-cards/.mp4 (+ -poster.png) +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from compose_feature_previews import RAW_DIR, constant_frame_rate, run # noqa: F401 + +ROOT = Path(__file__).resolve().parents[1] +OUT_DIR = ROOT / "docs" / "assets" / "feature-cards" +WORK = ROOT / ".tmp" / "feature-previews" / "cards" + +FPS = 30 +# Source-frame crop covering the keyboard chrome across every scenario. The +# clipboard panel is the tallest thing that appears there (top edge y=1779), +# and the bottom 72px of the screen is empty padding below the key row. +CROP_X, CROP_W = 0, 1290 +CROP_Y, CROP_H = 1780, 944 + +# Overlap used to make the cut back to frame 0 invisible. +LOOP_BLEND = 0.5 + + +@dataclass +class Card: + slug: str + # Raw recording slug under `.tmp/feature-previews/raw/`. + raw: str + # Window into the CFR recording, in real seconds. + start: float + duration: float + # Static pages have nothing to animate; zoom in then back out so the clip + # still moves and still loops perfectly. + ping_pong_zoom: float = 1.0 + # Crop override for clips that are not the keyboard (e.g. a settings page). + crop_y: int = CROP_Y + crop_h: int = CROP_H + + +def probe_duration(path: Path) -> float: + out = subprocess.check_output( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "json", str(path)], + text=True, + ) + return float(json.loads(out)["format"]["duration"]) + + +def encode_common(dest: Path) -> list[str]: + return [ + "-an", # cards are silent + "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-profile:v", "high", "-level", "4.0", "-crf", "20", + "-colorspace", "bt709", "-color_primaries", "bt709", + "-color_trc", "iec61966-2-1", "-color_range", "tv", + "-r", str(FPS), "-movflags", "+faststart", + str(dest), + ] + + +def build_motion(card: Card, work: Path) -> Path: + """Trim + crop the window, or build a ping-pong zoom over a frozen frame.""" + src = constant_frame_rate(card.raw) + dest = work / f"{card.slug}-motion.mp4" + crop = f"crop={CROP_W}:{card.crop_h}:{CROP_X}:{card.crop_y}" + + if card.ping_pong_zoom > 1.0: + frame = work / f"{card.slug}-still.png" + run(["ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src), + "-frames:v", "1", "-vf", crop, str(frame)]) + # Build only the zoom-in half and mirror it. Driving both directions + # from a single zoompan expression relies on its output frame count + # matching `duration * FPS`, which it does not — the clip then stops + # part-way back out and the loop visibly jumps. + half = card.duration / 2 + frames = max(2, int(half * FPS)) + step = (card.ping_pong_zoom - 1.0) / frames + vf = ( + f"zoompan=z='min(1+{step:.6f}*on,{card.ping_pong_zoom:.4f})'" + f":x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'" + f":d=1:s={CROP_W}x{card.crop_h}:fps={FPS},setsar=1" + ) + forward = work / f"{card.slug}-zoomin.mp4" + run([ + # A looped PNG defaults to 25 fps; without this the 30 fps zoompan + # emits 25/30 of the frames asked for and the clip comes up short. + "ffmpeg", "-y", "-loop", "1", "-framerate", str(FPS), + "-t", f"{half:.2f}", + "-i", str(frame), "-vf", vf, "-t", f"{half:.2f}", + *encode_common(forward), + ]) + run([ + "ffmpeg", "-y", "-i", str(forward), "-i", str(forward), + "-filter_complex", + "[1:v]reverse[r];[0:v][r]concat=n=2:v=1:a=0,format=yuv420p[v]", + "-map", "[v]", *encode_common(dest), + ]) + return dest + + run([ + "ffmpeg", "-y", "-ss", f"{card.start:.2f}", "-i", str(src), + "-vf", f"{crop},fps={FPS},setsar=1", + # The CFR pass already fixed timing, but clamp on the output side so + # the window length is exact regardless of keyframe placement. + "-t", f"{card.duration:.2f}", + *encode_common(dest), + ]) + return dest + + +def close_loop(source: Path, dest: Path, blend: float) -> None: + """Fade the tail back over the head so frame 0 and the last frame match.""" + total = probe_duration(source) + body = total - blend + chain = ( + f"[0:v]split=2[head][tail];" + f"[head]trim=start=0:end={body:.3f},setpts=PTS-STARTPTS[h];" + f"[tail]trim=start={body:.3f}:end={total:.3f},setpts=PTS-STARTPTS," + f"format=yuva420p,fade=t=out:st=0:d={blend:.3f}:alpha=1[t];" + f"[h][t]overlay=0:0:eof_action=pass,format=yuv420p[v]" + ) + run([ + "ffmpeg", "-y", "-i", str(source), + "-filter_complex", chain, "-map", "[v]", + *encode_common(dest), + ]) + + +CARDS: dict[str, Card] = {} + + +def build(card: Card) -> Path: + WORK.mkdir(parents=True, exist_ok=True) + OUT_DIR.mkdir(parents=True, exist_ok=True) + + motion = build_motion(card, WORK) + final = OUT_DIR / f"{card.slug}.mp4" + if card.ping_pong_zoom > 1.0: + # A ping-pong already ends where it started; blending would only + # duplicate frames. + motion.replace(final) + else: + close_loop(motion, final, LOOP_BLEND) + + poster = OUT_DIR / f"{card.slug}-poster.png" + run(["ffmpeg", "-y", "-i", str(final), "-frames:v", "1", str(poster)]) + return final + + +def main(argv: list[str]) -> int: + from feature_card_specs import CARDS as configured # noqa: PLC0415 + + slugs = argv or list(configured) + failures = 0 + for slug in slugs: + card = configured.get(slug) + if card is None: + sys.stderr.write(f"unknown card: {slug}\n") + failures += 1 + continue + final = build(card) + info = json.loads(subprocess.check_output( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height,nb_frames", + "-show_entries", "format=duration,size,nb_streams", + "-of", "json", str(final)], + text=True, + )) + stream, fmt = info["streams"][0], info["format"] + duration = float(fmt["duration"]) + silent = int(fmt["nb_streams"]) == 1 + ok = 5.0 <= duration <= 11.0 and silent + print( + f"{'OK ' if ok else '!! '}{final.name} " + f"{stream['width']}x{stream['height']} {duration:.1f}s " + f"{int(fmt['size']) / 1000:.0f} KB {'silent' if silent else 'HAS AUDIO'}" + ) + if not ok: + failures += 1 + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parent)) + raise SystemExit(main(sys.argv[1:])) diff --git a/Scripts/compose_feature_previews.py b/Scripts/compose_feature_previews.py new file mode 100755 index 0000000..531a435 --- /dev/null +++ b/Scripts/compose_feature_previews.py @@ -0,0 +1,441 @@ +#!/usr/bin/env python3 +"""Compose the four Chinese feature preview clips for OSGKeyboard. + +Takes raw Simulator screen recordings (1290x2796, iPhone 16 Plus) produced by +`Scripts/record_feature_previews.sh` and turns each into an App Store Connect +compliant preview: title card -> captioned footage -> end card, plus a soft +generated audio bed. + +This ffmpeg build ships without libfreetype / libass, so every piece of text is +rendered to RGBA PNG with Pillow and composited with `overlay`. + +Usage: + Scripts/compose_feature_previews.py [slug ...] + +Output: docs/assets/app-preview/zh/OSGKeyboard--6.7-zh.mp4 +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parents[1] +RAW_DIR = ROOT / ".tmp" / "feature-previews" / "raw" +WORK_ROOT = ROOT / ".tmp" / "feature-previews" / "work" +OUT_DIR = ROOT / "docs" / "assets" / "app-preview" / "zh" + +W, H = 1290, 2796 # App Store Connect 6.7" / 6.9" portrait +FPS = 30 + +ICON = ROOT / "docs" / "assets" / "app-icon.png" + +# No PingFang on every machine; Hiragino Sans GB is the cleanest CJK fallback. +FONT_CANDIDATES = [ + (Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 1), + (Path("/System/Library/Fonts/Hiragino Sans GB.ttc"), 0), + (Path("/System/Library/Fonts/STHeiti Medium.ttc"), 0), + (Path("/System/Library/Fonts/Supplemental/Songti.ttc"), 0), +] + +BG = (11, 13, 12) +FG = (245, 246, 248) +ACCENT = (52, 168, 96) + + +# -------------------------------------------------------------------------- +# Timeline description +# -------------------------------------------------------------------------- + + +@dataclass +class Caption: + """A lower-third super burned over the footage.""" + + start: float + end: float + text: str + # Second line, rendered smaller under `text`. + sub: str | None = None + + +@dataclass +class Source: + """One trimmed window of raw footage, with its own captions. + + A clip can chain several — the voice clip cuts from the polish flow in the + keyboard to the Styles page that owns the playful personalities. + """ + + # Raw recording slug, i.e. `.tmp/feature-previews/raw/.mov`. + raw: str + trim_start: float + trim_duration: float + captions: list[Caption] = field(default_factory=list) + # `simctl io recordVideo` only emits frames when the screen changes, so a + # static page yields a few seconds of footage no matter how long you wait. + # Set this to grab one frame at that timestamp and hold it for + # `trim_duration` instead, letting `ken_burns` supply the motion. + freeze_at: float | None = None + # Slow push-in over otherwise-static footage (e.g. a settings page that + # does not animate). Expressed as the final zoom factor; 1.0 disables it. + ken_burns: float = 1.0 + # Normalised focal point for the push-in — (0.5, 0.5) is dead centre. + ken_burns_focus: tuple[float, float] = (0.5, 0.5) + + +@dataclass +class Clip: + slug: str + # Title card copy. + title: list[str] + subtitle: str + sources: list[Source] + title_seconds: float = 2.4 + end_seconds: float = 2.6 + end_title: str = "OSGKeyboard" + end_subtitle: str = "开口即文字" + + @property + def body_seconds(self) -> float: + return sum(source.trim_duration for source in self.sources) + + @property + def total_seconds(self) -> float: + return self.title_seconds + self.body_seconds + self.end_seconds + + +def font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for path, index in FONT_CANDIDATES: + if not path.exists(): + continue + try: + return ImageFont.truetype(str(path), size=size, index=index) + except OSError: + continue + return ImageFont.load_default() + + +def text_width(draw: ImageDraw.ImageDraw, text: str, fnt) -> int: + box = draw.textbbox((0, 0), text, font=fnt) + return box[2] - box[0] + + +def centered(draw: ImageDraw.ImageDraw, y: int, text: str, fnt, fill) -> None: + draw.text(((W - text_width(draw, text, fnt)) // 2, y), text, font=fnt, fill=fill) + + +# -------------------------------------------------------------------------- +# Card + caption rendering +# -------------------------------------------------------------------------- + + +def title_card(lines: list[str], subtitle: str, with_icon: bool = True) -> Image.Image: + im = Image.new("RGB", (W, H), BG) + glow = Image.new("RGB", (W, H), BG) + gdraw = ImageDraw.Draw(glow) + cx, cy = W // 2, int(H * 0.40) + for radius, lift in ((560, 26), (380, 38), (230, 52)): + gdraw.ellipse( + (cx - radius, cy - radius, cx + radius, cy + radius), + fill=(BG[0] + lift // 5, BG[1] + lift // 2, BG[2] + lift // 4), + ) + im = Image.blend(im, glow, 0.6) + draw = ImageDraw.Draw(im) + + y = int(H * 0.36) + if with_icon and ICON.exists(): + icon = Image.open(ICON).convert("RGBA").resize((216, 216), Image.Resampling.LANCZOS) + mask = Image.new("L", (216, 216), 0) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, 215, 215), radius=48, fill=255) + im.paste(icon, ((W - 216) // 2, y - 300), mask) + + title_font = font(104 if len(lines) == 1 else 92) + for line in lines: + centered(draw, y, line, title_font, FG) + y += 132 + + if subtitle: + centered(draw, y + 28, subtitle, font(46), (146, 166, 150)) + return im + + +def caption_overlay(text: str, sub: str | None) -> Image.Image: + """Full-frame RGBA layer: bottom scrim + caption. Composited via overlay. + + The app UI is light, so the scrim has to carry real weight — a gentle + gradient leaves white supers unreadable over a white settings card. + """ + im = Image.new("RGBA", (W, H), (0, 0, 0, 0)) + draw = ImageDraw.Draw(im) + + scrim_height = 780 + for i in range(scrim_height): + # Sub-linear ramp: the supers sit well above the bottom edge, so the + # gradient has to carry real weight by the time it reaches them. + alpha = int(246 * (i / (scrim_height - 1)) ** 0.85) + draw.line([(0, H - scrim_height + i), (W, H - scrim_height + i)], fill=(0, 0, 0, alpha)) + + main_font = font(66) + baseline = H - 320 if sub else H - 268 + centered(draw, baseline, text, main_font, (255, 255, 255, 255)) + if sub: + centered(draw, baseline + 104, sub, font(44), (203, 221, 207, 255)) + return im + + +# -------------------------------------------------------------------------- +# ffmpeg plumbing +# -------------------------------------------------------------------------- + + +def run(cmd: list[str]) -> None: + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + sys.stderr.write(" ".join(cmd) + "\n") + sys.stderr.write(proc.stderr[-4000:] + "\n") + raise SystemExit(f"ffmpeg failed ({proc.returncode})") + + +def probe(path: Path) -> dict: + out = subprocess.check_output( + [ + "ffprobe", "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height,r_frame_rate,codec_name", + "-show_entries", "format=duration,size", + "-of", "json", + str(path), + ], + text=True, + ) + return json.loads(out) + + +def still_to_segment(image: Image.Image, seconds: float, dest: Path, zoom_in: bool) -> None: + src = dest.with_suffix(".png") + image.save(src, optimize=True) + frames = max(1, int(seconds * FPS)) + if zoom_in: + z = "min(zoom+0.00042,1.07)" + else: + z = "if(eq(on,1),1.07,max(zoom-0.00042,1.0))" + vf = ( + f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H}," + f"zoompan=z='{z}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':" + f"d={frames}:s={W}x{H}:fps={FPS},setsar=1,format=yuv420p" + ) + run([ + "ffmpeg", "-y", "-loop", "1", "-i", str(src), + "-vf", vf, "-t", f"{seconds:.2f}", + "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS), + "-colorspace", "bt709", "-color_primaries", "bt709", + "-color_trc", "iec61966-2-1", "-color_range", "tv", + str(dest), + ]) + + +def constant_frame_rate(slug: str) -> Path: + """Return a CFR copy of a raw recording, building it on first use. + + `simctl io recordVideo` writes variable-frame-rate footage whose timeline + does not advance at wall-clock rate, so seeking into it lands in the wrong + place and `-t` windows come out the wrong length. Normalising once up + front makes every `trim_start` / `trim_duration` below mean real seconds. + """ + raw = RAW_DIR / f"{slug}.mov" + if not raw.exists(): + raise SystemExit(f"missing recording: {raw}") + + cfr = WORK_ROOT / "cfr" / f"{slug}.mp4" + if cfr.exists() and cfr.stat().st_mtime >= raw.stat().st_mtime: + return cfr + + cfr.parent.mkdir(parents=True, exist_ok=True) + run([ + "ffmpeg", "-y", "-i", str(raw), + "-vf", f"fps={FPS},scale={W}:{H}:force_original_aspect_ratio=increase," + f"crop={W}:{H},setsar=1", + "-vsync", "cfr", + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "16", + "-colorspace", "bt709", "-color_primaries", "bt709", + "-color_trc", "iec61966-2-1", "-color_range", "tv", + "-r", str(FPS), str(cfr), + ]) + return cfr + + +def footage_segment(source: Source, work: Path, dest: Path, index: int) -> None: + """Trim one recording, normalise to 1290x2796, burn its captions.""" + raw = constant_frame_rate(source.raw) + + if source.freeze_at is None: + inputs: list[str] = [ + "-ss", f"{source.trim_start:.2f}", + "-t", f"{source.trim_duration:.2f}", + "-i", str(raw), + ] + else: + frame = work / f"freeze-{index:02d}.png" + run([ + "ffmpeg", "-y", "-ss", f"{source.freeze_at:.2f}", "-i", str(raw), + "-frames:v", "1", str(frame), + ]) + inputs = [ + "-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(frame), + ] + for idx, cap in enumerate(source.captions): + png = work / f"cap-{index:02d}-{idx:02d}.png" + caption_overlay(cap.text, cap.sub).save(png) + # A single-frame input has pts 0 forever, which freezes `fade` at its + # t=0 value and leaves the super permanently transparent. Loop it so + # the overlay carries a real timeline for the fades to run against. + inputs += ["-loop", "1", "-t", f"{source.trim_duration:.2f}", "-i", str(png)] + + # Normalise the recording first, then stack caption overlays on top. + normalise = ( + f"scale={W}:{H}:force_original_aspect_ratio=increase," + f"crop={W}:{H},fps={FPS}" + ) + if source.ken_burns > 1.0: + # Static pages (a settings screen) need the motion to come from the + # camera. Step per frame so the push-in lands exactly at `ken_burns`. + frames = max(1, int(source.trim_duration * FPS)) + step = (source.ken_burns - 1.0) / frames + fx, fy = source.ken_burns_focus + normalise += ( + f",zoompan=z='min(zoom+{step:.6f},{source.ken_burns:.4f})'" + f":x='(iw-iw/zoom)*{fx:.3f}':y='(ih-ih/zoom)*{fy:.3f}'" + f":d=1:s={W}x{H}:fps={FPS}" + ) + chain = [f"[0:v]{normalise},setsar=1[base]"] + current = "base" + for idx, cap in enumerate(source.captions): + nxt = f"ov{idx}" + # Fade the supers in and out so cuts do not pop. + chain.append( + f"[{idx + 1}:v]format=rgba," + f"fade=t=in:st={cap.start:.2f}:d=0.28:alpha=1," + f"fade=t=out:st={max(cap.start, cap.end - 0.28):.2f}:d=0.28:alpha=1[c{idx}]" + ) + chain.append( + f"[{current}][c{idx}]overlay=0:0:" + f"enable='between(t,{cap.start:.2f},{cap.end:.2f})'[{nxt}]" + ) + current = nxt + chain.append(f"[{current}]format=yuv420p[vout]") + + run([ + "ffmpeg", "-y", *inputs, + "-filter_complex", ";".join(chain), + "-map", "[vout]", + # `simctl io recordVideo` writes variable-frame-rate footage, so an + # input-side `-t` drifts once `fps=` fills the gaps. Clamp the output + # instead or the finished clip overruns the App Store 30s ceiling. + "-t", f"{source.trim_duration:.2f}", + "-c:v", "libx264", "-pix_fmt", "yuv420p", + "-profile:v", "high", "-level", "4.0", "-crf", "18", "-r", str(FPS), + "-colorspace", "bt709", "-color_primaries", "bt709", + "-color_trc", "iec61966-2-1", "-color_range", "tv", + str(dest), + ]) + + +def build(clip: Clip) -> Path: + work = WORK_ROOT / clip.slug + work.mkdir(parents=True, exist_ok=True) + OUT_DIR.mkdir(parents=True, exist_ok=True) + + title = work / "00-title.mp4" + still_to_segment(title_card(clip.title, clip.subtitle), clip.title_seconds, title, True) + parts = [title] + + for index, source in enumerate(clip.sources): + part = work / f"{index + 1:02d}-body.mp4" + footage_segment(source, work, part, index) + parts.append(part) + + end = work / "99-end.mp4" + still_to_segment( + title_card([clip.end_title], clip.end_subtitle), clip.end_seconds, end, False + ) + parts.append(end) + + concat = work / "concat.txt" + concat.write_text("".join(f"file '{p}'\n" for p in parts), encoding="utf-8") + + silent = work / "silent.mp4" + run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", + "-colorspace", "bt709", "-color_primaries", "bt709", + "-color_trc", "iec61966-2-1", "-color_range", "tv", + "-r", str(FPS), "-movflags", "+faststart", str(silent), + ]) + + # App previews need an audio track; generate a soft royalty-free bed. + total = clip.total_seconds + fade_out = max(0.0, total - 2.2) + final = OUT_DIR / f"OSGKeyboard-{clip.slug}-6.7-zh.mp4" + run([ + "ffmpeg", "-y", "-i", str(silent), + "-f", "lavfi", "-i", + f"sine=frequency=196:sample_rate=44100,volume=0.030," + f"afade=t=in:st=0:d=1.1,afade=t=out:st={fade_out:.2f}:d=2.0", + "-f", "lavfi", "-i", + f"sine=frequency=293.66:sample_rate=44100,volume=0.018," + f"afade=t=in:st=0:d=1.4,afade=t=out:st={fade_out:.2f}:d=2.0", + "-filter_complex", "[1:a][2:a]amix=inputs=2:duration=first:dropout_transition=2[a]", + "-map", "0:v", "-map", "[a]", + "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", + "-shortest", "-movflags", "+faststart", str(final), + ]) + return final + + +CLIPS: dict[str, Clip] = {} + + +def main(argv: list[str]) -> int: + from feature_preview_clips import CLIPS as configured # noqa: PLC0415 + + slugs = argv or list(configured) + failures = 0 + for slug in slugs: + clip = configured.get(slug) + if clip is None: + sys.stderr.write(f"unknown clip: {slug}\n") + failures += 1 + continue + final = build(clip) + info = probe(final) + duration = float(info["format"]["duration"]) + stream = info["streams"][0] + size_mb = int(info["format"]["size"]) / 1_000_000 + ok = ( + stream["width"] == W + and stream["height"] == H + and 15.0 <= duration <= 30.0 + and stream["codec_name"] == "h264" + ) + flag = "OK " if ok else "!! " + print( + f"{flag}{final.name} {stream['width']}x{stream['height']} " + f"{duration:.1f}s {size_mb:.1f} MB {stream['codec_name']}" + ) + if not ok: + failures += 1 + if not 15.0 <= duration <= 30.0: + print(f" duration {duration:.1f}s outside the App Store 15-30s window") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).resolve().parent)) + raise SystemExit(main(sys.argv[1:])) diff --git a/Scripts/feature_card_specs.py b/Scripts/feature_card_specs.py new file mode 100644 index 0000000..633abb4 --- /dev/null +++ b/Scripts/feature_card_specs.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Windows for the in-app feature cards. + +Every `start` / `duration` is in real seconds against the CFR copy of the raw +recording, and was read off a frame-by-frame pass over that footage — re-time +after touching any demo timeline. + +Windows are chosen so the clip both tells the feature in one glance and lands +close to where it started, which keeps the loop blend invisible. +""" + +from __future__ import annotations + +from compose_feature_cards import Card + +CARDS: dict[str, Card] = { + # 语音说一句 → 自动润色 → 出结果。 + # 5.5 空闲提示 · 6.5 聆听 · 10 编辑中 · 13 编辑后结果 + "voice-polish": Card( + slug="voice-polish", + raw="voice-polish", + start=5.5, + duration=10.0, + ), + # 润色风格页。静态页面,用极缓的推近-拉回提供动势,天然无缝。 + # 画面落在「生成专属风格」卡片上,下面带出内置风格第一排。 + "personal-style": Card( + slug="personal-style", + raw="personal-style", + start=4.0, + duration=8.0, + ping_pong_zoom=1.10, + # Starts below the page title — a half-cut「润色风格」heading reads as + # a framing mistake on a card. + crop_y=470, + ), + # 复制一条消息 → 键盘按内容给出技能行 → 点「回复」出一条 → 回到行 → + # 点「澄清追问」出另一种口吻。技能行由真实的 + # `ClipboardSkillSemanticRanker` 排出(这条消息排出「译为英语 / 回复 / + # 澄清追问 / 日程」),不是写死的——生产环境在通用「回复」之外最多只放 + # 两个专门回复技能,硬凑一排回复风格会得到一个真实 app 里不存在的键盘。 + # 21 技能行 · 23 思考 · 25 回复 · 27 技能行 · 29 澄清追问 · 30.5 技能行 + # 首尾都停在技能行上,循环点本身就是同一状态。 + "clipboard-agent": Card( + slug="clipboard-agent", + raw="clipboard-agent", + start=20.6, + duration=9.9, + ), + # 长按问 AI:聆听(识别出问题)→ 生成 → 答案落在键盘答案区。 + # 13.5 聆听 · 18 生成中 · 21 答案出现 + "ask-ai": Card( + slug="ask-ai", + raw="ask-ai", + start=13.5, + duration=10.0, + ), +} diff --git a/Scripts/feature_preview_clips.py b/Scripts/feature_preview_clips.py new file mode 100644 index 0000000..8889698 --- /dev/null +++ b/Scripts/feature_preview_clips.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Copy + timing for the four Chinese feature previews. + +Caption times are relative to the start of their own `Source` window (i.e. +after `trim_start`), not the finished file — title cards are prepended later. + +Kept separate from `compose_feature_previews.py` so the copy can be retimed +without touching the ffmpeg plumbing. + +Raw footage comes from `Scripts/record_feature_previews.sh`; every timing below +was read off those recordings, so re-time after changing a demo timeline. +""" + +from __future__ import annotations + +from compose_feature_previews import Caption, Clip, Source + +CLIPS: dict[str, Clip] = { + # 1 — 语音输入与润色,收在趣味润色风格上 + "voice-polish": Clip( + slug="voice-polish", + title=["说一句", "成一段"], + subtitle="语音输入 · 自动润色", + sources=[ + # 备忘录 + 真实润色审阅面板:听写 → 润色 → 替换上屏 + Source( + raw="voice-polish", + trim_start=3.4, + trim_duration=17.0, + captions=[ + Caption(0.3, 5.2, "开口就说", sub="不用先在脑子里组织措辞"), + Caption(5.5, 8.4, "AI 正在润色"), + Caption(8.7, 13.4, "口水话变成能直接发出去的话"), + Caption(13.7, 16.7, "一键替换,落回原处"), + ], + ), + # 润色风格页:内置风格之外还有一整排趣味人格 + Source( + raw="personal-style", + trim_start=0.0, + trim_duration=6.0, + freeze_at=4.0, + ken_burns=1.09, + ken_burns_focus=(0.5, 0.78), + captions=[ + Caption(0.3, 5.7, "换个风格,换种说法", + sub="大厂黑话 · 装逼指南 · 帝吧大神 · 小红书"), + ], + ), + ], + ), + # 2 — 个性润色风格:从历史听写里学你的表达习惯 + "personal-style": Clip( + slug="personal-style", + title=["它会学", "你说话"], + subtitle="从你的听写里长出专属风格", + sources=[ + Source( + raw="personal-style", + trim_start=0.0, + trim_duration=15.0, + freeze_at=4.0, + # Slow push-in onto the「生成专属风格」card near the top. + ken_burns=1.16, + ken_burns_focus=(0.5, 0.24), + captions=[ + Caption(0.3, 4.6, "内置风格之外,还有一个你"), + Caption(4.9, 9.4, "从历史听写里学你的表达习惯", + sub="用词、语气、断句,都是你的"), + Caption(9.7, 14.7, "一键生成专属风格", + sub="仅在生成时发送,保存前可预览可修改"), + ], + ), + ], + ), + # 3 — 剪贴板 AI Agent:复制的内容直接一键回复 + "clipboard-agent": Clip( + slug="clipboard-agent", + title=["复制一段", "AI 帮你回"], + subtitle="剪贴板 AI Agent", + sources=[ + Source( + raw="clipboard-agent", + # Beats in the recording: clipboard panel ~9-13s, suggestion + # strip to ~19s, AI skill row ~19-26s, reply lands ~33s. The + # 24s budget cannot hold all of it, so the window opens on the + # tail of the panel and runs through the payoff. + trim_start=11.0, + trim_duration=24.0, + captions=[ + Caption(0.3, 3.6, "复制过的内容,键盘直接认得"), + Caption(3.9, 7.4, "不用来回切 App 粘来粘去"), + # Skill row is on screen for body 7.6-12.6 — keep the super + # inside that window or it names a row you cannot see. + Caption(7.8, 12.4, "一键回复", + sub="俏皮回复 · 译为英语 · 总结网页"), + Caption(19.5, 23.7, "点一下,回复就写好了"), + ], + ), + ], + ), + # 4 — 长按麦克风直接询问 AI + "ask-ai": Clip( + slug="ask-ai", + title=["轻点听写", "长按问 AI"], + subtitle="一个麦克风,两件事", + sources=[ + Source( + raw="ask-ai", + trim_start=9.0, + trim_duration=24.0, + captions=[ + Caption(0.3, 5.4, "长按麦克风,直接问 AI", + sub="不用退出当前 App"), + Caption(5.7, 11.0, "说完就在想答案"), + Caption(11.3, 17.4, "答案直接落进输入框"), + Caption(17.7, 23.7, "想好了,直接发出去"), + ], + ), + ], + ), +} diff --git a/Scripts/record_feature_previews.sh b/Scripts/record_feature_previews.sh new file mode 100755 index 0000000..2930943 --- /dev/null +++ b/Scripts/record_feature_previews.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Record the four Chinese feature preview clips off an iOS Simulator. +# +# Drives the DEBUG in-app demo hosts, which render the **real** keyboard views +# (`AIKeyboardView`, `LastInputEditView`, `ClipboardHistoryPanelView`, …) and +# the real `PolishStylesView` against a scripted `KeyboardState` — no ASR, no +# LLM, no network. `--preview-fullscreen` adds the Notes / Messages host +# document above the keyboard band so the frame works as a full-screen App +# Store preview instead of a cropped What's New card. +# +# Why not the keyboard extension (`--whats-new-host`)? Making a third-party +# keyboard the *active* one needs a globe-key tap that cannot be scripted +# through simctl. The in-app hosts use the same views and need no tap. +# +# Device must be an iPhone 16 Plus (native 1290x2796 — the App Store Connect +# 6.9" preview slot) on a runtime new enough for the app's Liquid Glass APIs +# (iOS 26.5 at time of writing; iOS 26.0 is missing `View.glassEffect(_:in:)`). +# +# Usage: +# Scripts/record_feature_previews.sh [slug ...] +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +UDID="${1:?usage: record_feature_previews.sh [slug ...]}" +shift || true + +BUNDLE="com.osgkeyboard.ios" +RAW_DIR="$ROOT/.tmp/feature-previews/raw" +mkdir -p "$RAW_DIR" + +# slug | seconds | launch args +CLIPS=( + "voice-polish|23|--edit-demo --preview-fullscreen" + # Static page — the motion is a post-production push-in, so this only needs + # to be long enough to fill the App Store 15s minimum after trimming. + "personal-style|22|--polish-styles-screenshot" + "clipboard-agent|46|--clipboard-demo --preview-fullscreen" + "ask-ai|36|--ai-demo --preview-fullscreen --whats-new-lang=zh" +) + +want() { + local slug="$1"; shift + # No slug filter left on the command line -> record everything. + [[ $# -eq 0 ]] && return 0 + for requested in "$@"; do + [[ "$slug" == "$requested" ]] && return 0 + done + return 1 +} + +echo "==> Device: $UDID" + +# Installing over an existing copy does not refresh the App Group container. +# When it is missing the app renders `AppGroupErrorView` instead of the demo, +# and the recording silently captures an error screen — so fail loudly here. +DEVICE_ROOT="$HOME/Library/Developer/CoreSimulator/Devices/$UDID" +if ! grep -qls "group.com.osgkeyboard.shared" \ + "$DEVICE_ROOT"/data/Containers/Shared/AppGroup/*/.com.apple.mobile_container_manager.metadata.plist +then + echo "FAIL: App Group container missing on $UDID." >&2 + echo " Reinstall cleanly, then retry:" >&2 + echo " xcrun simctl uninstall $UDID $BUNDLE && xcrun simctl install $UDID .app" >&2 + exit 1 +fi + +# Marketing status bar: fixed time, full bars, full battery. +xcrun simctl status_bar "$UDID" override \ + --time "9:41" \ + --dataNetwork wifi --wifiMode active --wifiBars 3 \ + --cellularMode active --cellularBars 4 \ + --batteryState charged --batteryLevel 100 + +for entry in "${CLIPS[@]}"; do + IFS='|' read -r slug seconds args <<<"$entry" + want "$slug" "$@" || continue + + out="$RAW_DIR/$slug.mov" + echo "==> Recording $slug (${seconds}s) [$args]" + + xcrun simctl terminate "$UDID" "$BUNDLE" >/dev/null 2>&1 || true + sleep 1 + + rm -f "$out" + xcrun simctl io "$UDID" recordVideo --codec h264 --force "$out" & + RECORD_PID=$! + # Let the recorder attach before the first frame we care about. + sleep 1.5 + + # shellcheck disable=SC2086 -- args are intentionally word-split. + xcrun simctl launch "$UDID" "$BUNDLE" $args >/dev/null + + sleep "$seconds" + + kill -INT "$RECORD_PID" 2>/dev/null || true + wait "$RECORD_PID" 2>/dev/null || true + sleep 1 + + if [[ -s "$out" ]]; then + ffprobe -v error -select_streams v:0 \ + -show_entries stream=width,height -show_entries format=duration \ + -of default=noprint_wrappers=1 "$out" + else + echo "FAIL: no footage captured for $slug" >&2 + exit 1 + fi +done + +xcrun simctl terminate "$UDID" "$BUNDLE" >/dev/null 2>&1 || true +echo "==> Raw footage in $RAW_DIR" diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index 5992ac0..a82ffdc 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -154,6 +154,7 @@ "OSGKeyboardTests/FlowKeyboardPoliciesTests", "OSGKeyboardTests/FlowReliabilityTests", "OSGKeyboardTests/FlowSessionManagerAnalyticsTests", + "OSGKeyboardTests/FlowFailureLogStoreTests", "OSGKeyboardTests/AISessionStateTests" ] }, @@ -203,7 +204,6 @@ "tests": [ "OSGKeyboardTests/HostAppURLRegistryTests", "OSGKeyboardTests/PublicContentRefreshServiceTests", - "OSGKeyboardTests/TipProductTests", "OSGKeyboardTests/OpenSourceLicenseCatalogTests" ] }, diff --git a/docs/ACCOUNT_MANAGED_GATEWAY_TESTING.md b/docs/ACCOUNT_MANAGED_GATEWAY_TESTING.md index ebb60df..a2bc297 100644 --- a/docs/ACCOUNT_MANAGED_GATEWAY_TESTING.md +++ b/docs/ACCOUNT_MANAGED_GATEWAY_TESTING.md @@ -105,8 +105,8 @@ difference until the server request schema supports them. ## StoreKit credits -The existing `ByRockyACoffee` product remains a voluntary consumable and never -grants credits. Configure `500tks` for 500 credits at USD 0.99, `1500tks` for +The app ships three consumable credit packs and no other in-app purchase. +Configure `500tks` for 500 credits at USD 0.99, `1500tks` for 1,500 credits at USD 1.99 / CNY 18, and `3000tks` for 3,000 credits at USD 2.99 / CNY 28. @@ -123,5 +123,4 @@ USD 2.99 / CNY 28. once. 6. Sign in to another OSG account and verify the first account's transaction is rejected. -7. Confirm there is no Restore Purchases action for credit packs and that the - voluntary tip still changes only the local support count. +7. Confirm there is no Restore Purchases action for credit packs. diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index c0e7797..885b381 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -32,7 +32,7 @@ | Field | Value | |---|---| | Price | Free | -| In-App Purchases | Optional consumables: tip `ByRockyACoffee`; managed-credit packs `500tks`, `1500tks`, `3000tks` | +| In-App Purchases | Optional consumables: managed-credit packs `500tks`, `1500tks`, `3000tks` | | Availability | All configured App Store territories | | Pre-order | No | @@ -153,16 +153,14 @@ OSGKeyboard is a custom keyboard for iOS/iPadOS 26. transactions are finished. 7. AI polish and AI mode can use either managed credits or a user-owned provider key. Without either, local dictation still inserts recognized text. -8. Optional tip `ByRockyACoffee` remains a consumable support purchase and - does not grant managed credits or unlock features. -9. Clipboard history is off by default. To test it, open Settings → +8. Clipboard history is off by default. To test it, open Settings → Clipboard, enable History, copy text on this device or through Universal Clipboard, then return to the keyboard. Secure fields hide the clipboard entry point. Turning History off preserves saved items; use the separate confirmed clear action to delete them. -10. First-party Product Analytics is enabled by default under Settings → - About → Privacy. Turning it off deletes queued events. It does not collect - keyboard input, audio, transcripts, prompts, model output, or credentials. +9. First-party Product Analytics is enabled by default under Settings → + About → Privacy. Turning it off deletes queued events. It does not collect + keyboard input, audio, transcripts, prompts, model output, or credentials. Privacy policy: https://hkgood.github.io/OSGKeyboard/privacy/ @@ -280,17 +278,50 @@ output, audio, or keyboard content. `Info.plist` declares `ITSAppUsesNonExemptEncryption = false`. Network calls use standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. +## Screenshots + +OSGKeyboard ships for **iPhone and iPad** (`TARGETED_DEVICE_FAMILY: "1,2"`), so +both device families need screenshots. Apple scales one uploaded set down to +smaller devices, so only the largest size per family is required. + +| Set | Capture device | Pixel size | Required | +|---|---|---|---| +| iPhone 6.9" | iPhone 17 Pro Max | 1320 × 2868 (or 1290 × 2796) | Yes | +| iPad 13" | iPad Pro 13" (M4) | 2064 × 2752 (or 2048 × 2732) | Yes, because the app supports iPad | + +Apple adjusts accepted sizes over time — confirm the current list in the +App Store Connect media manager before capturing. + +### Capture procedure + +1. Run the **submitted build** on the simulators above (Xcode 26). +2. Capture with `xcrun simctl io booted screenshot ~/Desktop/shot.png`. + Simulator captures contain no device bezel, which Apple requires. +3. Verify pixel dimensions; do not upscale a smaller capture. +4. Upload per device family in App Store Connect. Screenshots are **not** + stored in this repository; `docs/assets/screenshots/` holds the smaller, + web-optimized images used by the GitHub Pages site only. + +### Scenes, in order + +1. **Keyboard at rest** — OSGKeyboard in a text field, not recording +2. **Dictation in progress** — recording state with a partial transcript visible +3. **Chinese / English typing** — candidate bar with pinyin candidates or the + three-slot English QuickType bar +4. **AI assistant** — a streamed answer inside the keyboard before insertion +5. **Clipboard skills** — recommended actions after a copy +6. **Home** — monthly usage calendar, metrics, and personal dictionary + ## Submission checklist - [ ] Confirm `project.yml` still reads version 2.0.3 / build 94 - [ ] Open the existing Xcode project (do not regenerate unless needed) - [ ] Run the release build and test suites on macOS with Xcode 26 -- [ ] Replace screenshots with captures from the submitted build +- [ ] Capture fresh iPhone 6.9" and iPad 13" screenshots from the submitted + build (see Screenshots above) - [ ] Verify the privacy answers against the submitted provider features - [ ] In App Store Connect, add Device ID, Product Interaction, and Other Usage Data for Analytics; linked to the user, not used for tracking - [ ] Confirm `500tks`, `1500tks`, and `3000tks` are approved, consumable, and mapped to the server credit catalog -- [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks - no feature - [ ] Upload, select build 94, add review notes, and submit diff --git a/docs/FEATURE_PREVIEW_RECORDING.md b/docs/FEATURE_PREVIEW_RECORDING.md new file mode 100644 index 0000000..44b45a4 --- /dev/null +++ b/docs/FEATURE_PREVIEW_RECORDING.md @@ -0,0 +1,145 @@ +# 功能预览片录制 / Feature preview recording + +四段中文 App Store 预览片的录制与合成流程。成片位于 +`docs/assets/app-preview/zh/OSGKeyboard--6.7-zh.mp4`。 + +| slug | 讲什么 | 素材来源 | +|------|--------|----------| +| `voice-polish` | 语音输入与自动润色,收在趣味润色风格 | `--edit-demo` + 润色风格页 | +| `personal-style` | 从历史听写学习你的表达习惯,生成专属风格 | `--polish-styles-screenshot` | +| `clipboard-agent` | 剪贴板 AI Agent,一键回复消息 | `--clipboard-demo` | +| `ask-ai` | 轻点听写、长按问 AI | `--ai-demo` | + +## 为什么不用键盘扩展录 + +`WhatsNewDemoDriver` 那条路(`--whats-new-host`)会让**真实键盘扩展**盖在 +Notes 宿主上,画面最真。但把第三方键盘设成**当前**输入法必须点一下地球键, +`simctl` 没有对应命令,`.GlobalPreferences` 里的 `AppleKeyboards` 只决定 +"已启用"而不是"当前使用"。 + +所以这套流程改走 app 内的 DEBUG demo host:它们渲染的是**同一批真实键盘视图** +(`AIKeyboardView`、`LastInputEditView`、`ClipboardHistoryPanelView`……,见 +`project.yml` 中 app target 的 `OSGKeyboardExt/Views/*` 源),由脚本化的 +`KeyboardState` 驱动,不走 ASR、不走 LLM、不联网,也就不需要任何点击。 + +`--preview-fullscreen` 会在键盘上方补一块 Notes / Messages 宿主文档 +(`FeaturePreviewHostDocument`),把原本留给 What's New 裁剪的空白填满, +让整帧可以直接当全屏预览片用。不传这个 flag 时,各 demo view 的画面与 +What's New 工作流完全一致。 + +## 设备要求 + +- **iPhone 16 Plus**:原生 1290×2796,正好是 App Store Connect 6.9" 预览位 + 接受的尺寸之一,零缩放。 +- **iOS 26.5 或更新**:app 用到的 `SwiftUI.View.glassEffect(_:in:)` 在 iOS 26.0 + 运行时里不存在,装上去会在启动时 `Symbol not found` 崩溃。 + +Xcode 预置的 iPhone 16 Plus 挂在 iOS 26.0 下,需要自建一台: + +```bash +xcrun simctl create "OSG-Preview-16Plus" \ + com.apple.CoreSimulator.SimDeviceType.iPhone-16-Plus \ + com.apple.CoreSimulator.SimRuntime.iOS-26-5 +``` + +## 完整流程 + +```bash +# 1) 编译(必须带签名,否则没有 entitlements → App Group 不可用 → 启动崩溃) +xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ + -configuration Debug -destination "id=$UDID" \ + -onlyUsePackageVersionsFromResolvedFile CODE_SIGNING_ALLOWED=YES build + +# 2) 干净安装。覆盖安装不会刷新 App Group 容器, +# 容器缺失时 app 会渲染 AppGroupErrorView,录出来就是一屏报错。 +xcrun simctl uninstall "$UDID" com.osgkeyboard.ios +xcrun simctl install "$UDID" \ + ~/Library/Developer/Xcode/DerivedData/OSGKeyboard-*/Build/Products/Debug-iphonesimulator/OSGKeyboard.app + +# 3) 录制(脚本会先校验 App Group 容器,缺失直接报错退出) +Scripts/record_feature_previews.sh "$UDID" # 全部四段 +Scripts/record_feature_previews.sh "$UDID" ask-ai # 只录一段 + +# 4) 合成。需要 Pillow —— 这版 ffmpeg 没有 libfreetype / libass, +# 所有文字都由 PIL 渲染成 RGBA PNG 再 overlay。 +python3 -m venv .venv && .venv/bin/pip install Pillow +.venv/bin/python Scripts/compose_feature_previews.py +``` + +## 已知的坑 + +**变帧率**。`simctl io recordVideo` 录的是 VFR,时间轴不按墙钟走:直接 +`-ss` 会定位到错误位置,`-t` 截出来的长度也不对(实测 24s 的窗口出来 27.4s, +足以让成片超出 App Store 的 30 秒上限)。`compose_feature_previews.py` 会先把 +每段原始素材转成 30fps CFR 再裁,所以配置里的 `trim_start` / `trim_duration` +是真实秒数。 + +**静态页面录不出长片**。VFR 只在画面变化时出帧,纯静态页面录 22 秒也只得到 +约 5 秒素材。`Source(freeze_at=…)` 会抽一帧定格,配合 `ken_burns` 用运镜提供 +动势 —— `personal-style` 就是这么做的。 + +**字幕 PNG 必须 `-loop 1`**。单帧输入的 pts 恒为 0,`fade` 的时间轴永远停在 +起点,alpha 被钉死,字幕完全不显示。 + +**色彩标签会丢**。原始素材是 `tv` range / bt709 / sRGB transfer,重编码不显式 +带上就变成 `unknown`,播放器猜错会明显发灰。合成脚本每一次编码都显式打标签。 + +**抽帧检查会骗你**。用 ffmpeg 抽出 PNG 再看,色彩管理与实际播放不同,画面会 +偏灰;判断压暗层强度请量像素值,不要看缩略图。 + +## App 内卡片版(无字幕短循环) + +同一批原始素材还会产出第二种成品:给 app 内卡片用的短循环,位于 +`docs/assets/feature-cards/.mp4`,另附 `-poster.png` 作占位图。 + +```bash +.venv/bin/python Scripts/compose_feature_cards.py +``` + +与预览片的区别:**没有字幕、没有片头片尾、没有音轨**,并且裁到键盘区 +(`1290×944`),画面里只有真实键盘 chrome,不含任何为讲故事补的宿主界面。 + +窗口都选在首尾状态相近的位置,再叠一次 0.5 秒的尾接头淡入,所以可以无限循环 +播放而看不出接缝(实测首末帧平均像素差 0.19–0.57 / 255)。`personal-style` 是 +静态设置页,改用推近再拉回的对称运镜,天然闭合。 + +裁切区取 `y=1780` 起、高 944:剪贴板面板是键盘区里最高的元素(顶边 y=1779), +屏幕最底部 72px 是键盘下方的空白内边距,一并去掉。 + +### 技能行必须由真实排序器产生 + +`AIKeyboardView` 的技能行读 `ClipboardHistoryStore.shared` 与 +`ClipboardSemanticRankingStore.shared` 两个**单例**,并交给 +`ClipboardSkillSemanticRanker.recommended(...)` 按剪贴板内容排序。demo view 早期 +用私有 store + `AIKeyboardView.debugPreviewSkills` 强塞了一排回复风格,结果录出 +一个真实 app 里**不可能出现**的键盘——排序器在通用「回复」之外最多只放 +`maximumReplyRecommendations = 2` 个专门回复技能。 + +现在 `ClipboardHistoryDemoView` 直接喂真实单例:把要回复的那条消息 ingest 成 +最新剪贴板项,调 `ranking.analyze(entry)` 并**等它出 snapshot**(分析在主 actor 外 +跑,抢跑会让行退化成孤零零一个「回复」),然后由排序器决定行内容。当前这条 +消息排出的是「译为英语 / 回复 / 澄清追问 / 日程」。 + +演示脚本只从排出来的行里挑技能点——先点通用「回复」,再点行内第一个 +`supportsReplyStyle` 的技能——所以画面里点的永远是屏幕上真实存在的 chip。 + +app 里**没有**「点一次回复,弹出多条候选」这种交互:点一个技能生成一条。 +多风格是靠技能行里并列的多个回复技能实现的,先选风格再生成。 + +一个容易踩的坑:`AISessionState` 的 `beginGenerating` / `receivePartialAnswer` / +`receiveAnswer` 都用 `activeUtteranceID == utteranceID` 做守卫,而这个 ID 只由 +`beginPreparing` 设置。跳过 `beginPreparing` 直接 `beginGenerating`,整串调用会 +**静默变成空操作**,键盘区从头到尾不出现任何答案。每一轮都要 +`enter()` → `beginPreparing` → `beginGenerating`。 + +两个 ffmpeg 细节:`zoompan` 的 `d=1` 是按输入帧走的,而 `-loop 1` 读 PNG 默认 +**25fps**,不加 `-framerate 30` 出来的片长会短 25/30;另外推拉运镜不要用单条 +`zoompan` 表达式同时做进和出——它的实际出帧数对不上 `duration * FPS`,片子会 +停在半路,循环处肉眼可见地跳。现在的做法是只渲染推近的一半再 `reverse` 拼接。 + +## 改动了 demo 时间轴之后 + +`Scripts/feature_preview_clips.py` 里的裁切点和字幕时间是照着实际录像量出来的。 +只要改了 `EditDemoView` / `AIKeyboardDemoView` / `ClipboardHistoryDemoView` 的 +时间轴,就要重录并重新对时。合成脚本会校验成片是否落在 App Store 要求的 +15–30 秒、1290×2796、H.264 之内,不合规会在输出里标 `!!`。 diff --git a/docs/RELEASE_NOTES_1.8.0.md b/docs/RELEASE_NOTES_1.8.0.md deleted file mode 100644 index d4f688b..0000000 --- a/docs/RELEASE_NOTES_1.8.0.md +++ /dev/null @@ -1,109 +0,0 @@ -# OSGKeyboard 1.8.0 更新说明 - -> 以下为 OSGKeyboard 从 1.6.6 升级至 1.8.0 的关键变化。 - -## 新功能 - -### AI Agent - -- 语音听写与 AI 问答合并为全新的助手模式:轻点麦克风开始听写,长按麦克风可直接语音提问。 -- 新增热点与情境热词,麦克风区域会展示天气、节日、新闻、热搜等建议;点按即可向 AI 获取相关信息。 -- AI 回答会实时显示在键盘中,确认后可插入当前输入框;支持的服务商可联网搜索,并在失败时自动回退。 -- 仅当原输入框与光标上下文仍然匹配时自动插入 AI 回答;上下文变化时会保留回答,等待用户明确插入或丢弃。 -- 设置 → AI Agent 可调节回复篇幅:简短、中等或详细。 - -### 剪贴板与 AI 技能 - -- 新增剪贴板历史。在设置 → 剪贴板开启“历史记录”后,可在本机保存最近 15 条纯文本;该功能默认关闭。 -- 新增剪贴板建议条。可从键盘顶栏打开历史并点按插入;开启建议条后,最新复制内容会显示在键盘上方。 -- 复制文字后约 30 秒内,助手会显示回复、总结、翻译等快捷技能;技能支持分页浏览,并可在技能页调整顺序。 -- 新增技能页,最多可将 8 个技能放入键盘。内置技能可提取待办、添加日历日程、存入备忘录,或识别地址后打开高德、百度与 Apple 地图导航。 -- 新增自定义 AI 技能,可设置名称、图标和提示词,也可选择连接自己的 iCloud 快捷指令。 - -### 编辑上次输入 - -- 编辑入口移至键盘右下角。点按后可口述修改要求、对比原文与编辑结果,并选择替换原文或追加内容。 -- 编辑只使用最近一次由 OSGKeyboard 插入且仍可验证的文字,不会读取输入框中的无关内容。 - -## 全新 App 界面 - -- iPhone 底部导航重做为“首页 / 技能 / 风格 / 设置”四栏 Liquid Glass Dock,并以绿色胶囊标示当前页面。 -- 首页改为信息总览,将使用统计、听写历史和个性词库集中显示为资料卡。 -- 技能与润色风格采用统一的卡片式管理:点按选择、右上角编辑、右下角显示启用状态;技能支持长按拖动排序。 -- 设置入口重新整理,AI Agent、剪贴板、语音与文字输入等页面更容易找到,并直接显示当前设置摘要。 -- iPad 改为侧栏 + 内容区布局;键盘横竖屏均充分利用可用宽度,并加入系统地球键、撤销、重做、复制和剪切等操作。 - -## 中英文输入全面升级 - -- 中英文输入均可在本机学习用户的输入习惯、候选频率和选择偏好;设置中可单独清除打字习惯,不会删除个性词库。 -- 拼音优化混合简拼排序,并支持 `zh`、`ch`、`sh` 两键简拼。 -- 英文键盘新增类 QuickType 候选栏,同时显示原词、纠错和补全;约 4 万词离线词表会结合系统词库、通讯录名称与文本替换提供建议。 -- 英文邻键纠错更贴合真实键位,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词。 -- 支持叠指连打、英文双空格句号,以及根据输入框显示前往、搜索、发送、完成、下一项等操作。 - -## 稳定性、隐私与重要变化 - -- 听写、AI 回答、编辑结果和剪贴板粘贴可通过统一的撤销操作回滚。 -- 修复切换键盘崩溃、中文输入初始化后无法恢复、通用剪贴板导致键盘卡住,以及 AI 会话结束后麦克风不可用等问题。 -- 全部内置与自定义润色风格都会保留问句原意,不再把用户的问题改写成回答。 -- 设备端语音识别仍为默认选项;AI 使用用户自己配置的服务商与 API Key,不再提供内置 DeepSeek 回退。 -- 剪贴板历史默认关闭,内容保存在本机,并仅在用户主动调用技能后发送给所配置的 AI 服务商。 -- 长按麦克风现用于向 AI 提问;编辑上次输入改用右下角编辑按钮。旧的长按剪贴板语音指令已由剪贴板历史和快捷技能取代。 -- 已安装旧中文名称“待办”或“日程”快捷指令的用户,需要从技能页重新安装 `OSGExtractTodos` 和 `OSGExtractEvents`。 -- 已移除空白区域滑动光标及其设置开关。 - ---- - -# OSGKeyboard 1.8.0 Release Notes - -> Key changes when upgrading OSGKeyboard from 1.6.6 to 1.8.0. - -## New Features - -### AI Agent - -- Voice dictation and AI questions now share one Assistant mode: tap the microphone to dictate, or hold it to ask a question by voice. -- New trending and contextual hotwords surface suggestions for weather, holidays, news, trending topics, and more; tap one to ask AI about it. -- AI answers stream directly into the keyboard for review and insertion. Supported providers can search the web and fall back automatically when needed. -- AI answers insert automatically only while the original field and cursor context still match. If the context changes, the answer is retained for explicit insertion or discard. -- Settings → AI Agent now offers Short, Medium, and Detailed response lengths. - -### Clipboard and AI Skills - -- New Clipboard History keeps the latest 15 plain-text copies on this device when enabled in Settings → Clipboard. It is off by default. -- A new Clipboard suggestion strip lets you open history from the keyboard, insert an item, or keep the latest copy above the keyboard. -- For about 30 seconds after copying text, Assistant shows quick Reply, Summarize, Translate, and other skills. Skills support paging and can be reordered from the Skills page. -- The new Skills page lets you place up to eight skills on the keyboard. Built-in skills extract reminders, add calendar events, save to Notes, or open navigation in Amap, Baidu Maps, or Apple Maps. -- Custom AI Skills support your own name, icon, and prompt, with an optional iCloud Shortcut connection. - -### Edit Last Input - -- Edit moves to the bottom-right keyboard button. Describe the change by voice, compare the original and edited text, then replace or append the result. -- Editing uses only the latest verifiable text inserted by OSGKeyboard and does not read unrelated field content. - -## A New App Interface - -- The iPhone dock is rebuilt around four Liquid Glass destinations: Home, Skills, Styles, and Settings, with a green capsule marking the current page. -- Home becomes an overview of usage statistics, dictation history, and Personal Dictionary cards. -- Skills and polish styles share a card-based interaction: tap to select, edit from the top-right, see enabled status at the bottom-right, and long-press skills to reorder. -- Settings navigation is reorganized so AI Agent, Clipboard, Speech, and Text Input are easier to find, with current summaries shown beside each row. -- iPad adopts a sidebar + detail layout. The keyboard fills the available portrait and landscape width and adds the system globe key, undo, redo, copy, and cut. - -## Chinese and English Typing, Rebuilt - -- Chinese and English typing learn candidate frequency and selection preferences locally. Typing habits can be cleared without deleting Personal Dictionary entries. -- Pinyin improves mixed abbreviations and supports two-key `zh`, `ch`, and `sh` abbreviations. -- The English keyboard adds a QuickType-style bar for the typed word, correction, and completion. An offline list of about 40,000 words works with the system lexicon, contact names, and text replacements. -- Neighbor-key correction follows the physical keyboard while avoiding unwanted changes to names, short words, and all-caps text. Rejecting a correction teaches the original. -- Overlapping key presses, the English double-space period, and contextual Go, Search, Send, Done, and Next actions make typing feel closer to the system keyboard. - -## Stability, Privacy, and Important Changes - -- One Undo action now rolls back dictation, AI answers, edits, and clipboard pastes. -- Fixes include keyboard-switch crashes, Chinese input recovery after setup, Universal Clipboard freezes, and a microphone that could remain unavailable after AI sessions. -- Every built-in and custom polish style preserves questions instead of rewriting them as answers. -- On-device speech recognition remains the default. AI uses your configured provider and API key; the built-in DeepSeek fallback has been removed. -- Clipboard History stays off by default, keeps its contents on device, and sends text to the configured AI provider only after you invoke a skill. -- Holding the microphone now asks AI, while Edit Last Input moves to the bottom-right Edit button. Clipboard History and quick skills replace the old hold-to-command clipboard flow. -- If you installed the older Chinese-named Tasks or Events Shortcuts, reinstall `OSGExtractTodos` and `OSGExtractEvents` from the Skills page. -- Blank-area cursor sliding and its Settings toggle have been removed. diff --git a/docs/STOREKIT_TESTING.md b/docs/STOREKIT_TESTING.md index b239f37..831c0ef 100644 --- a/docs/STOREKIT_TESTING.md +++ b/docs/STOREKIT_TESTING.md @@ -1,113 +1,132 @@ -# StoreKit — Voluntary Support Tip +# StoreKit — managed credit packs -OSGKeyboard offers a single **Consumable** in-app purchase: +OSGKeyboard ships **three Consumable** in-app purchases and no subscriptions. +All three add credits to the optional OSG managed account. The authoritative +local catalog is [`OSGKeyboard.storekit`](../OSGKeyboard.storekit); App Store +Connect must match it exactly. -| Field | Value | -|---|---| -| **Type** | Consumable | -| **Product ID** | `ByRockyACoffee` | -| **Reference name (Connect)** | 给老刘买杯咖啡 | -| **Display name (en)** | Buy me a cup of coffee | -| **Display name (zh-Hans)** | 给老刘买杯冰美式 | -| **Price (China)** | ¥28 (nearest available App Store tier) | -| **Price (US, suggested)** | $3.99 equivalent tier | +| Product ID | Type | Reference name | Display name (en) | Price (CN) | Grants | +|---|---|---|---|---|---| +| `500tks` | Consumable | 500 OSG 积分 | 500 OSG Credits | ¥8 | 500 account credits | +| `1500tks` | Consumable | 1,500 OSG 积分 | 1,500 OSG Credits | ¥18 | 1,500 account credits | +| `3000tks` | Consumable | 3,000 OSG 积分 | 3,000 OSG Credits | ¥28 | 3,000 account credits | -**Important:** this tip does **not** unlock translation, dictionary limits, -long Flow sessions, or any other capability. The app remains fully free. +Rules that apply to all three: -Consumable purchases **cannot be restored** (Apple policy). Settings copy -explains this to users. +- Consumables **cannot be restored** (Apple policy). The UI states this. +- Nothing here gates a feature. Local dictation, BYOK providers, typing, + clipboard skills, and iCloud sync stay free with or without a purchase. +- Credits only add balance to the optional OSG account. They are useless + without Sign in with Apple, and the app never requires an account. +- Credit packs are **iOS / iPadOS only**. The macOS build ships via Developer + ID outside the Mac App Store, so it loads no StoreKit products at all. + +## Where the packs appear + +iOS Home → credit card → Account Center, and Settings → Account Center. Both +require Sign in with Apple. + +## Purchase flow (server-verified) + +Credit packs are not client-trusted. `AccountCreditPurchaseManager`: + +1. Buys with StoreKit 2, binding `appAccountToken` to the signed-in account ID. +2. Rejects any transaction whose `appAccountToken` or product ID does not match + the configured set. +3. Submits the signed transaction to the account service + (`submitCreditTransaction`) and checks the returned transaction and product + IDs against the local ones. +4. Calls `transaction.finish()` **only after** the server confirms and the + ledger is updated. + +A purchase that fails server verification stays unfinished and is retried from +the transaction-updates stream, so a crashed or backgrounded app does not lose +credits. --- ## App Store Connect setup 1. Open **App Store Connect → OSGKeyboard → In-App Purchases**. -2. Create **Consumable** with Product ID `ByRockyACoffee`. -3. Add localizations (en + zh-Hans) using the strings in - `OSGKeyboardShared/*/Shared.strings` (`tip.*` keys) and - `docs/APPSTORE_METADATA.md`. -4. Set pricing: **China ¥28** (App Store only offers fixed tiers — ¥28 is - the nearest to ¥30); pick equivalent tiers for other territories. -5. Submit the IAP for review **with** the app version that includes the - Settings → Support the Developer entry. +2. Create each of the three products as **Consumable** with the exact Product + IDs above. Do not rename an existing ID — Apple treats it as a new product. +3. Add en + zh-Hans localizations. Copy comes from + [`OSGKeyboard.storekit`](../OSGKeyboard.storekit) (`localizations`) and + [`docs/APPSTORE_METADATA.md`](APPSTORE_METADATA.md). +4. Set pricing to the nearest App Store tier for China (¥8 / ¥18 / ¥28) and let + Apple map the equivalent tiers elsewhere. +5. Submit the IAPs for review **together with** the app version that exposes + them. A first IAP submitted without a build is rejected as incomplete. -### Review screenshot (required — fixes「元数据丢失」) +### Review screenshot (required — otherwise「元数据丢失」) -App Store Connect → IAP **ByRockyACoffee** → **审核信息** → **截屏**: +Each product needs a screenshot under **审核信息 → 截屏**: run the app signed +in, open **Home → credit card → Account Center**, and capture the pack list +showing localized prices. -1. Run the app (Simulator or device) with Settings open at the top - **支持开发者** card showing the green **打赏 ¥28.00** button. -2. Capture that screen (⌘S in Simulator, or device screenshot). -3. Upload to **截屏 → 选取文件**. -4. Optional **审核备注**: +Optional **审核备注**: ``` -Optional voluntary tip only (Consumable IAP ByRockyACoffee). -All features free before and after purchase. Settings tab → top of page. -Consumable — cannot restore (stated in UI). +Consumable credits for the optional OSG managed speech/AI service. +Requires Sign in with Apple. Local dictation and user-supplied API keys +remain free and never require credits. Consumable — cannot restore. ``` Save — status should become **准备提交**. --- -## Local testing (StoreKit Test — no Connect / sandbox account) +## Local testing (StoreKit Test — no Connect, no sandbox account) -Uses [`OSGKeyboard.storekit`](../OSGKeyboard.storekit). The **OSGKeyboard** -scheme already references it in `project.yml` (`storeKitConfiguration`). +The **OSGKeyboard** scheme already references `OSGKeyboard.storekit` in +`project.yml` (`storeKitConfiguration`). -1. `xcodegen generate && open OSGKeyboard.xcodeproj` -2. **Product → Scheme → Edit Scheme → Run → Options** - - Confirm **StoreKit Configuration** = `OSGKeyboard.storekit` -3. Run **OSGKeyboard** on Simulator (e.g. iPhone 17) or a plugged-in device -4. Open **Settings** tab → top card **支持开发者** -5. Tap **打赏 ¥28.00** → StoreKit Test purchase sheet appears -6. **Buy** → thank-you alert; **Cancel** → no error -7. Repeat buy once (Consumable allows multiple) +1. `./Scripts/generate-xcodeproj.sh && open OSGKeyboard.xcodeproj` +2. **Product → Scheme → Edit Scheme → Run → Options** — confirm + **StoreKit Configuration** = `OSGKeyboard.storekit` +3. Run **OSGKeyboard** on a simulator (e.g. iPhone 17) or a device. +4. Sign in with Apple, then open **Home → credit card → Account Center**. +5. Buy `500tks` → balance increases by 500 after the account service confirms; + the purchase-history row appears. +6. Cancel a purchase → returns to idle with no error spam. +7. Repeat a purchase → allowed (Consumable). -**Debug menu (optional):** Xcode → **Debug → StoreKit → Manage Transactions** -to view / delete test purchases. +**Debug menu:** Xcode → **Debug → StoreKit → Manage Transactions** to inspect +or delete test purchases. + +Automated coverage: `AccountCreditPurchaseManagerTests` (in the hermetic +manifest — see [TESTING.md](TESTING.md)) and `AccountCenterUITests`, which runs +separately via `xcodebuild -scheme OSGKeyboardUITests` against the `IAPReview` +stub harness — see +[ACCOUNT_MANAGED_GATEWAY_TESTING.md](ACCOUNT_MANAGED_GATEWAY_TESTING.md). --- -## Sandbox testing (real App Store sandbox — after Connect IAP is 准备提交) +## Sandbox testing (after Connect IAPs reach 准备提交) -1. App Store Connect → **用户和访问** → **沙盒** → create a **Sandbox Tester** -2. On device: **设置 → App Store → 沙盒账户** → sign in (not your real Apple ID) -3. Install via **TestFlight** or **Debug run without** `.storekit`: - - To hit Connect products: Edit Scheme → Run → Options → set StoreKit - Configuration to **None**, then run on device -4. Settings → **支持开发者** → purchase with sandbox account -5. Sandbox charges are free; receipt is real sandbox flow +1. App Store Connect → **用户和访问 → 沙盒** → create a **Sandbox Tester**. +2. On device: **设置 → App Store → 沙盒账户** → sign in (not your real Apple ID). +3. Install via **TestFlight**, or Debug-run with **StoreKit Configuration = + None** so the app hits Connect products. +4. Buy each pack once and confirm the server-side ledger, not just the UI. +5. Sandbox charges are free; the receipt path is the real one. + +The full server-side matrix (replayed transactions, interrupted network, +wrong-account rejection) lives in +[ACCOUNT_MANAGED_GATEWAY_TESTING.md](ACCOUNT_MANAGED_GATEWAY_TESTING.md). --- -## Submit to App Review (after local + sandbox pass) +## Release checklist -Yes — **wait until testing looks good**, then: - -1. IAP **ByRockyACoffee** status = **准备提交** (pricing + screenshot + localizations) -2. Bump app version in `project.yml` / `CHANGELOG.md` if needed -3. **Archive** → upload build to App Store Connect -4. Open the new **App Store version** page → **App 内购买项目** → **+** → select **ByRockyACoffee** -5. Fill metadata, attach build, submit **version + IAP together** (first IAP rule) - -### Sandbox checklist - -- [ ] Product loads and shows localized price -- [ ] Successful purchase shows thank-you alert +- [ ] All three products load and show localized prices +- [ ] Each pack adds the correct balance after server verification +- [ ] Purchase history lists the new transaction without duplicates +- [ ] A failed/interrupted verification is retried and not lost - [ ] User cancel returns to idle (no error spam) - [ ] Repeat purchase works (Consumable allows multiple) -- [ ] No Restore button for this product (Consumable) -- [ ] Translation, Flow, dictionary, BYOK unchanged after tipping - -### macOS note - -The menu-bar Mac build (`com.osgkeyboard.mac`) ships via Developer ID outside -the Mac App Store today. StoreKit products load only for App Store builds. -The Mac Settings UI is present for parity; tip IAP requires an App Store -distribution if Mac tipping is enabled later. +- [ ] No Restore button for any of these products +- [ ] Local dictation, BYOK, typing, and clipboard skills unchanged after purchase --- @@ -115,8 +134,7 @@ distribution if Mac tipping is enabled later. | File | Role | |---|---| -| `OSGKeyboardShared/Services/Tip/TipProduct.swift` | Product ID constants | -| `OSGKeyboardShared/Services/Tip/TipPurchaseManager.swift` | StoreKit 2 purchase flow | -| `OSGKeyboardShared/DesignSystem/SupportDeveloperSection.swift` | iOS Settings UI | -| `OSGKeyboardMac/MacSupportDeveloperTipRows.swift` | macOS Settings UI | +| `OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift` | Purchase + server verification | +| `OSGKeyboard/Views/Account/AccountCreditStore.swift` | StoreKit 2 product/purchase abstraction | +| `OSGKeyboard/Views/Account/AccountCenterView.swift` | Credit pack UI, purchase history | | `OSGKeyboard.storekit` | Local StoreKit Test catalog | diff --git a/docs/TYPING_KEYBOARD_BASELINE.md b/docs/TYPING_KEYBOARD_BASELINE.md index dc232df..ba3566e 100644 --- a/docs/TYPING_KEYBOARD_BASELINE.md +++ b/docs/TYPING_KEYBOARD_BASELINE.md @@ -1,5 +1,10 @@ # Typing Keyboard Baseline (Phase 0) +> **文档状态**:Phase 0 冻结基线(历史快照,2026-08-03)。它记录当时的选型依据, +> 不随版本更新。**当前权威的依赖版本与许可以 `NOTICE-TYPING.md` 和 +> `OSGKeyboard/Resources/Typing/Rime/manifest.json` 为准**;实施进展见 +> `docs/keyboard-accuracy-plan.md`。 + Frozen reference for NanoMouse × OSGKeyboard fusion. ## NanoMouse reference diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..74175f2 --- /dev/null +++ b/docs/archive/README.md @@ -0,0 +1,10 @@ +# Archived documents + +Point-in-time documents kept for traceability only. **They are not maintained +and do not describe current behavior.** For current facts use `CHANGELOG.md`, +`docs/APPSTORE_METADATA.md`, and the living docs directly under `docs/`. + +| Document | What it was | Why it is archived | +|---|---|---| +| [ios-pip-voice-session-plan.md](ios-pip-voice-session-plan.md) | PiP voice-session keep-alive plan (2026-07-26) | Implemented in 1.6.5; its Live Activity assumption was removed in 1.6.6 | +| [mac-parity-audit-2026-08-23.md](mac-parity-audit-2026-08-23.md) | iOS ↔ macOS parity audit at 2.0.1 (build 90) | One-off snapshot; superseded by later releases | diff --git a/docs/ios-pip-voice-session-plan.md b/docs/archive/ios-pip-voice-session-plan.md similarity index 95% rename from docs/ios-pip-voice-session-plan.md rename to docs/archive/ios-pip-voice-session-plan.md index 228612a..3d863b2 100644 --- a/docs/ios-pip-voice-session-plan.md +++ b/docs/archive/ios-pip-voice-session-plan.md @@ -1,8 +1,10 @@ # iOS PiP 语音会话保活规划 -> **文档状态**:产品与架构规划(待验证,未进入实现) -> **适用范围**:iOS 主 App + 键盘扩展 + Live Activity -> **目标版本**:待产品验证后确定 +> **文档状态**:**历史规划(已实现,部分结论已被取代)**——保留用于追溯设计取舍,不代表当前行为 +> **实际落地**:自动低感知 PiP 于 **1.6.5** 上线(`OSGKeyboard/Services/FlowPictureInPictureController.swift`) +> **主要偏差**:本文假设 Live Activity 继续承担锁屏与灵动岛展示;**1.6.6 已删除 Live Activity 扩展与全部 ActivityKit 代码**,语音会话仅保留静默低感知 PiP,用户可见文案不出现「画中画」 +> **当前事实以何为准**:`CHANGELOG.md`、`docs/FLOW_BLUETOOTH_TESTING.md`、`docs/keyboard-memory-budget.md` +> **适用范围**:iOS 主 App + 键盘扩展 > **创建日期**:2026-07-26 --- diff --git a/docs/mac-parity-audit-2026-08-23.md b/docs/archive/mac-parity-audit-2026-08-23.md similarity index 99% rename from docs/mac-parity-audit-2026-08-23.md rename to docs/archive/mac-parity-audit-2026-08-23.md index babc1bc..95de447 100644 --- a/docs/mac-parity-audit-2026-08-23.md +++ b/docs/archive/mac-parity-audit-2026-08-23.md @@ -1,5 +1,6 @@ # OSGKeyboard · iOS 完整检查 + macOS 差距与移植建议报告 +> **文档状态**:**一次性审计快照(历史存档)**——仅代表 2026-08-23 当时的代码状态,其后 Mac 与 iOS 均有改动(如总览页已改为月度用量日历),不要作为现行文档使用 > **报告日期**:2026-08-23 > **当前版本**:2.0.1(build 90)— `project.yml:51-52` > **审查范围**:iOS Host (`OSGKeyboard/`, 88 .swift) + iOS HostSupport (`OSGKeyboardHostSupport/`, 29 .swift) + iOS Extension (`OSGKeyboardExt/`, 27 .swift) + Shared (`OSGKeyboardShared/`, 230 .swift) + macOS (`OSGKeyboardMac/`, 39 .swift) + macOS Tests (3 .swift) diff --git a/docs/assets/app-preview/zh/OSGKeyboard-ask-ai-6.7-zh.mp4 b/docs/assets/app-preview/zh/OSGKeyboard-ask-ai-6.7-zh.mp4 new file mode 100644 index 0000000..16cacb9 Binary files /dev/null and b/docs/assets/app-preview/zh/OSGKeyboard-ask-ai-6.7-zh.mp4 differ diff --git a/docs/assets/app-preview/zh/OSGKeyboard-clipboard-agent-6.7-zh.mp4 b/docs/assets/app-preview/zh/OSGKeyboard-clipboard-agent-6.7-zh.mp4 new file mode 100644 index 0000000..b8e569b Binary files /dev/null and b/docs/assets/app-preview/zh/OSGKeyboard-clipboard-agent-6.7-zh.mp4 differ diff --git a/docs/assets/app-preview/zh/OSGKeyboard-personal-style-6.7-zh.mp4 b/docs/assets/app-preview/zh/OSGKeyboard-personal-style-6.7-zh.mp4 new file mode 100644 index 0000000..ae9d54f Binary files /dev/null and b/docs/assets/app-preview/zh/OSGKeyboard-personal-style-6.7-zh.mp4 differ diff --git a/docs/assets/app-preview/zh/OSGKeyboard-voice-polish-6.7-zh.mp4 b/docs/assets/app-preview/zh/OSGKeyboard-voice-polish-6.7-zh.mp4 new file mode 100644 index 0000000..69b49bb Binary files /dev/null and b/docs/assets/app-preview/zh/OSGKeyboard-voice-polish-6.7-zh.mp4 differ diff --git a/docs/assets/feature-cards/ask-ai-poster.png b/docs/assets/feature-cards/ask-ai-poster.png new file mode 100644 index 0000000..d2b9f5c Binary files /dev/null and b/docs/assets/feature-cards/ask-ai-poster.png differ diff --git a/docs/assets/feature-cards/ask-ai.mp4 b/docs/assets/feature-cards/ask-ai.mp4 new file mode 100644 index 0000000..372fd88 Binary files /dev/null and b/docs/assets/feature-cards/ask-ai.mp4 differ diff --git a/docs/assets/feature-cards/clipboard-agent-poster.png b/docs/assets/feature-cards/clipboard-agent-poster.png new file mode 100644 index 0000000..9631f54 Binary files /dev/null and b/docs/assets/feature-cards/clipboard-agent-poster.png differ diff --git a/docs/assets/feature-cards/clipboard-agent.mp4 b/docs/assets/feature-cards/clipboard-agent.mp4 new file mode 100644 index 0000000..f013f5e Binary files /dev/null and b/docs/assets/feature-cards/clipboard-agent.mp4 differ diff --git a/docs/assets/feature-cards/personal-style-poster.png b/docs/assets/feature-cards/personal-style-poster.png new file mode 100644 index 0000000..794ddfa Binary files /dev/null and b/docs/assets/feature-cards/personal-style-poster.png differ diff --git a/docs/assets/feature-cards/personal-style.mp4 b/docs/assets/feature-cards/personal-style.mp4 new file mode 100644 index 0000000..bf62c75 Binary files /dev/null and b/docs/assets/feature-cards/personal-style.mp4 differ diff --git a/docs/assets/feature-cards/voice-polish-poster.png b/docs/assets/feature-cards/voice-polish-poster.png new file mode 100644 index 0000000..07d8da6 Binary files /dev/null and b/docs/assets/feature-cards/voice-polish-poster.png differ diff --git a/docs/assets/feature-cards/voice-polish.mp4 b/docs/assets/feature-cards/voice-polish.mp4 new file mode 100644 index 0000000..ca5378b Binary files /dev/null and b/docs/assets/feature-cards/voice-polish.mp4 differ diff --git a/docs/keyboard-accuracy-plan.md b/docs/keyboard-accuracy-plan.md index 38247d6..2a8d634 100644 --- a/docs/keyboard-accuracy-plan.md +++ b/docs/keyboard-accuracy-plan.md @@ -1,11 +1,12 @@ # 打字键盘输入准确率提升计划 -> **文档状态**:产品与工程规划(**实现中**:Phase 1~4 已落地,待真机试打) +> **文档状态**:**Phase 1~4 已随 1.6.1(2026-08-05)发布**;本文保留为设计依据与验收脚本,剩余工作见下方「剩余事项」 > **适用范围**:iOS 键盘扩展打字面(`OSGKeyboardExt` 打字键网格)+ 共享布局度量(`OSGKeyboardShared`) > **关联基线**:`docs/TYPING_KEYBOARD_BASELINE.md` -> **实现分支**:`feat/keyboard-touch-accuracy` +> **已落地代码**:`OSGKeyboardShared/Typing/KeyHitTesting.swift`、`TypingKeyLayout.swift`、`PinyinNextKeyResolver.swift`、`OSGKeyboardExt/Typing/TypingKeyTouchPad.swift`(原分支 `feat/keyboard-touch-accuracy` 已合入 `main` 并删除) > **创建日期**:2026-08-03 -> **目标版本**:待 Phase 0 基线试打后确定发布节奏 +> +> **剩余事项**:附录 A 的真机试打脚本尚未留下正式打分记录;Phase 5(气泡预览等加分项)未启动。 --- diff --git a/docs/keyboard-memory-budget.md b/docs/keyboard-memory-budget.md index 3207e73..e4b5f8d 100644 --- a/docs/keyboard-memory-budget.md +++ b/docs/keyboard-memory-budget.md @@ -76,6 +76,6 @@ operation-specific context. Filter Console by `OSGDiag/memory` and compare: ## Structural split - Extension links **OSGKeyboardShared** only (no Charts / StoreKit / Speech / HostSupport). -- Host embeds **OSGKeyboardHostSupport** (ASR, CLM, CloudASR, tip/charts UI). +- Host embeds **OSGKeyboardHostSupport** (ASR, CLM, CloudASR, charts UI). - Heavy assets (`osg_pinyin.dict.yaml`, CLM bin, licenses, local-asr catalog) ship in the **host app** bundle; extension reads Rime from App Group after deploy. diff --git a/docs/local-asr-architecture.md b/docs/local-asr-architecture.md index 1175ce9..0aa6f65 100644 --- a/docs/local-asr-architecture.md +++ b/docs/local-asr-architecture.md @@ -1,6 +1,6 @@ # OSGKeyboard 本地 ASR 技术架构 -> **文档状态**:1.7.0 代码事实 + 后续评测方向 +> **文档状态**:Mac 本地 ASR 现行架构说明(结论已复核至 **2.0.3 / build 94**,2026-08-31)+ 后续评测方向 > **适用范围**:macOS 15+ 本地听写;iOS 26+ 使用 Apple SpeechAnalyzer。 > **当前结论**:Mac 默认安装目录模型为 > `qwen3-mlx-0.6b-4bit`,通过 `mlx-audio-swift` 做 Qwen3 MLX 真流式识别; diff --git a/docs/osgkeyboardversion.html b/docs/osgkeyboardversion.html index b636604..defc91d 100644 --- a/docs/osgkeyboardversion.html +++ b/docs/osgkeyboardversion.html @@ -178,6 +178,63 @@

暂无与当前版本匹配的更新说明。

No release notes match this app version.

+ +
+
2.0.3
+ +
+
+

英文个性词推荐

+
    +
  • 除中文输入习惯外,键盘现在也会学习你反复使用的英文生词,并推荐加入个人词库
  • +
  • 中英文推荐词都需要你确认后才会加入,原有的中文学习记录不受影响
  • +
+
+ +
+

界面一致性优化

+
    +
  • 统一卡片间距、设置页字体和菜单对齐方式,账号与购买记录的排版更整齐
  • +
  • 一级页面与详情页之间的标签栏过渡更自然
  • +
+
+ +
+

问题修复

+
    +
  • 重装 App 或重新回到前台后,Apple 账号登录状态恢复更可靠,登录过程中的加载提示保持一致
  • +
  • 在不同 App 之间切换后,中英文候选词可靠恢复,且不会残留上一个输入框的内容
  • +
+
+
+ +
+
+

English Personal-Term Suggestions

+
    +
  • Alongside Chinese typing habits, the keyboard now learns rare English words you use repeatedly and suggests adding them to your Personal Dictionary
  • +
  • Suggestions in either language are added only after you confirm, and existing Chinese learning history is preserved
  • +
+
+ +
+

Interface Consistency

+
    +
  • Card spacing, settings typography, menu alignment, and the account and purchase-history rows are now consistent
  • +
  • Tab-bar transitions between primary pages and detail pages feel smoother
  • +
+
+ +
+

Fixes

+
    +
  • Apple sign-in is restored more reliably after a reinstall or when returning to the foreground, with a consistent loading state
  • +
  • Chinese and English candidates recover reliably after switching apps, without leaking state from the previous text field
  • +
+
+
+
+
2.0.2
diff --git a/docs/personal-dictionary-icloud-kvs-checklist.md b/docs/personal-dictionary-icloud-kvs-checklist.md index 74ef92a..4f52028 100644 --- a/docs/personal-dictionary-icloud-kvs-checklist.md +++ b/docs/personal-dictionary-icloud-kvs-checklist.md @@ -1,12 +1,12 @@ # Personal Dictionary iCloud KVS — Manual Verification Checklist -Use this on **macOS with Xcode 16+** and at least two devices signed into the **same Apple ID** with iCloud Drive / iCloud enabled. +Use this on **macOS with Xcode 26** (the repository build requirement) and at least two devices signed into the **same Apple ID** with iCloud Drive / iCloud enabled. ## Prerequisites 1. In Apple Developer Portal, enable **iCloud** → **Key-value storage** for `com.osgkeyboard.ios`. 2. Regenerate provisioning profiles after entitlements change. -3. Run `xcodegen generate` and install a fresh build on each device. +3. Run `./Scripts/generate-xcodeproj.sh` and install a fresh build on each device. ## Scenarios diff --git a/docs/screenshots/6.1/01-keyboard-default.png b/docs/screenshots/6.1/01-keyboard-default.png deleted file mode 100644 index 7914cab..0000000 Binary files a/docs/screenshots/6.1/01-keyboard-default.png and /dev/null differ diff --git a/docs/screenshots/6.1/02-flow-session.png b/docs/screenshots/6.1/02-flow-session.png deleted file mode 100644 index 65a244b..0000000 Binary files a/docs/screenshots/6.1/02-flow-session.png and /dev/null differ diff --git a/docs/screenshots/6.1/03-on-device-asr.png b/docs/screenshots/6.1/03-on-device-asr.png deleted file mode 100644 index 582c438..0000000 Binary files a/docs/screenshots/6.1/03-on-device-asr.png and /dev/null differ diff --git a/docs/screenshots/6.1/04-llm-polish.png b/docs/screenshots/6.1/04-llm-polish.png deleted file mode 100644 index f31a8c6..0000000 Binary files a/docs/screenshots/6.1/04-llm-polish.png and /dev/null differ diff --git a/docs/screenshots/6.1/05-providers.png b/docs/screenshots/6.1/05-providers.png deleted file mode 100644 index 347026a..0000000 Binary files a/docs/screenshots/6.1/05-providers.png and /dev/null differ diff --git a/docs/screenshots/6.7/01-keyboard-default.png b/docs/screenshots/6.7/01-keyboard-default.png deleted file mode 100644 index cf787cd..0000000 Binary files a/docs/screenshots/6.7/01-keyboard-default.png and /dev/null differ diff --git a/docs/screenshots/6.7/02-flow-session.png b/docs/screenshots/6.7/02-flow-session.png deleted file mode 100644 index 7728ca4..0000000 Binary files a/docs/screenshots/6.7/02-flow-session.png and /dev/null differ diff --git a/docs/screenshots/6.7/03-on-device-asr.png b/docs/screenshots/6.7/03-on-device-asr.png deleted file mode 100644 index d4d4cbf..0000000 Binary files a/docs/screenshots/6.7/03-on-device-asr.png and /dev/null differ diff --git a/docs/screenshots/6.7/04-llm-polish.png b/docs/screenshots/6.7/04-llm-polish.png deleted file mode 100644 index 6e5f2e4..0000000 Binary files a/docs/screenshots/6.7/04-llm-polish.png and /dev/null differ diff --git a/docs/screenshots/6.7/05-providers.png b/docs/screenshots/6.7/05-providers.png deleted file mode 100644 index caf375a..0000000 Binary files a/docs/screenshots/6.7/05-providers.png and /dev/null differ diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md deleted file mode 100644 index 031cf4f..0000000 --- a/docs/screenshots/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# App Store Screenshots - -> ⚠️ **PLACEHOLDERS.** The 10 PNGs in this directory are -> automatically generated blanks produced by -> `scripts/generate_screenshot_placeholders.py` and must be -> **replaced with real Simulator screenshots** before App Store -> submission. They use the correct dimensions (1290×2796 for -> 6.7", 1179×2556 for 6.1") so the upload validator will accept -> them, but they contain no real UI. - -## Required dimensions (2026) - -| Size | Devices | Dimensions | Apple requirement | -|------|---------|-----------|---| -| 6.7" | iPhone 17 Pro Max, 17, 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus | 1290 × 2796 px | **Required** (3-10 images) | -| 6.1" | iPhone 17 Pro, 17, 16 Pro, 16, 15 Pro, 15, 14 Pro, 14 | 1179 × 2556 px | **Required** (3-10 images) | -| 5.5" | iPhone 8 Plus (legacy) | 1242 × 2208 px | Optional since 2024 | - -iPad screenshots are not required because OSGKeyboard is iPhone-only -(`TARGETED_DEVICE_FAMILY = 1`). - -## Layout - -``` -docs/screenshots/ -├── 6.7/ ← 1290×2796 (iPhone 17 Pro Max / 17) -│ ├── 01-keyboard-default.png -│ ├── 02-flow-session.png -│ ├── 03-on-device-asr.png -│ ├── 04-llm-polish.png -│ └── 05-providers.png -└── 6.1/ ← 1179×2556 (iPhone 17 Pro / 17) - ├── 01-keyboard-default.png - ├── 02-flow-session.png - ├── 03-on-device-asr.png - ├── 04-llm-polish.png - └── 05-providers.png -``` - -## How to capture real screenshots - -1. Open `OSGKeyboard.xcodeproj` in Xcode 26+ -2. Run on **iPhone 17 Pro** simulator (6.1" set) and **iPhone 17 Pro Max** simulator (6.7" set) -3. For each scene: - ```bash - # Take a screenshot of the simulator window - xcrun simctl io booted screenshot ~/Desktop/shot.png - ``` -4. Process for App Store (Apple rejects frames containing the device bezel — full-screen content only): - ```bash - # The simulator screenshot already has a thin device frame. - # Open in Preview, crop to full screen (⌘+K with ⌥ for precision), - # export as PNG at 1290×2796 or 1179×2556. - sips -z 2796 1290 shot.png --out final-6.7.png - sips -z 2556 1179 shot.png --out final-6.1.png - ``` -5. Replace the placeholders with the real captures, keeping the - same filenames so the App Store Connect → Version → Uploads UI - auto-pairs by file. - -## Scenes to capture - -The 5 placeholders are intentional scene placeholders. Capture these -*exact* screens, in this order: - -1. **Keyboard at rest** — the iOS keyboard, OSGKeyboard mode, no recording -2. **Flow session active** — keyboard with the green/orange recording ring, - partial transcript visible in the host text field -3. **On-device ASR** — Settings view with the locale list, the on-device - indicator (iPhone icon) visible next to ≥ 3 supported locales -4. **LLM polish** — Settings view with the API provider card, a sample - "polish" transformation shown in the inline preview -5. **Providers** — API Settings card scrolled to show all 6 provider logos diff --git a/project.yml b/project.yml index 2544b2d..e7e1dc3 100644 --- a/project.yml +++ b/project.yml @@ -556,6 +556,21 @@ targets: - "AppIcon.appiconset" - path: OSGKeyboardMac - path: OSGKeyboard/Services/OpenSourceLicenseCatalog.swift + # Account stack shared with the iOS app at the source level. These files + # are platform-agnostic (no UIKit); the iOS-only SwiftUI account center + # stays behind in OSGKeyboard/Views/Account and Mac ships MacAccountView. + # AccountCreditStore / AccountCreditPurchaseManager compile here because + # AccountSessionCoordinator owns one, but Mac never surfaces purchase UI: + # Developer ID builds cannot use StoreKit. Credits are bought on iOS/web + # and only consumed here. + - path: OSGKeyboard/Views/Account/AccountCenterModels.swift + - path: OSGKeyboard/Views/Account/AccountSessionCoordinator.swift + - path: OSGKeyboard/Views/Account/AccountCreditStore.swift + - path: OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift + - path: OSGKeyboard/Views/Account/LiveAccountServices.swift + - path: OSGKeyboard/Views/Account/ReferralProfileViewModel.swift + - path: OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift + - path: OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift - path: OSGKeyboardHostSupport excludes: # iOS SpeechAnalyzer / AVAudioSession capture — Mac has its own paths. @@ -621,16 +636,36 @@ targets: entitlements: path: OSGKeyboardMac/OSGKeyboardMac.entitlements properties: - # Sandbox disabled on purpose: this menu-bar dictation utility uses the - # Accessibility API to monitor a global hotkey and inject ⌘V into other - # apps — both are forbidden by App Sandbox. Ship via Developer ID + - # notarization (not Mac App Store / TestFlight, which force the sandbox). + # Sandbox disabled on purpose. Precisely: App Sandbox does not itself + # ban the Accessibility API — TCC does, and sandboxed Mac App Store + # apps can be granted it. What makes the sandbox a bad trade here is + # the combination this app needs: a global `.flagsChanged` monitor over + # every keystroke (MacHotkeyService) plus a synthesized ⌘V into other + # apps (MacTextInsertionService). That pairing is a review risk not + # worth taking just to gain StoreKit. Ship Developer ID + notarized. + # + # Consequence for the account stack: no in-app purchases here. Credits + # are bought on iOS/web and only spent on Mac. Sign in with Apple and + # the account API need no sandbox and no Mac App Store. + # NSPasteboard, for the record, is unrestricted under the sandbox — + # clipboard capture was never the reason to opt out. com.apple.security.app-sandbox: false com.apple.security.device.audio-input: true com.apple.security.network.client: true com.apple.developer.ubiquity-kvstore-identifier: $(TeamIdentifierPrefix)com.osgkeyboard.ios + # Sign in with Apple needs no sandbox and no Mac App Store — a + # Developer ID + notarized build is enough. The Mac App ID must be + # grouped under the iOS primary App ID in the developer portal, or the + # same person gets a different Apple `sub` here than on iOS and the + # server treats them as two accounts. + com.apple.developer.applesignin: + - Default keychain-access-groups: - $(AppIdentifierPrefix)com.osgkeyboard.shared + # Host-private account credentials (session tokens, Apple user id). + # Never the .shared group — that one is reachable by the iOS keyboard + # extension; see HostPrivateAccountKeychainDescriptor. + - $(AppIdentifierPrefix)com.osgkeyboard.mac info: path: OSGKeyboardMac/Info.plist properties: @@ -642,6 +677,7 @@ targets: - en - zh-Hans LSApplicationCategoryType: public.app-category.utilities + OSGPrivateKeychainAccessGroup: $(AppIdentifierPrefix)com.osgkeyboard.mac NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider." NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition for local dictation mode." NSHumanReadableCopyright: "OSGKeyboard" diff --git a/scripts/generate_screenshot_placeholders.py b/scripts/generate_screenshot_placeholders.py deleted file mode 100644 index 83284db..0000000 --- a/scripts/generate_screenshot_placeholders.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -""" -generate_screenshot_placeholders.py - -Generates App Store screenshot placeholders for OSGKeyboard. -Apple App Store Connect accepts: - - 6.7" (iPhone 17 Pro Max, 17, 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus) → 1290 × 2796 - - 6.1" (iPhone 17 Pro, 17, 16 Pro, 16, 15 Pro, 15, 14 Pro) → 1179 × 2556 - -We produce 5 of each (Apple requires 3 minimum, accepts up to 10). - -These are INTENTIONALLY bland placeholders — the marketing team should -screenshot the actual app running in the iOS 26 Simulator and replace -these before final upload. The script only verifies dimensions and -labels. - -Run: - python3 scripts/generate_screenshot_placeholders.py -""" -import os -import sys -from PIL import Image, ImageDraw, ImageFont - -REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -OUT_DIR = os.path.join(REPO_ROOT, "docs", "screenshots") - -SCREENS = [ - { - "id": "01-keyboard-default", - "headline": "Hold to talk", - "subtitle": "Push-to-talk from any app's text field.", - }, - { - "id": "02-flow-session", - "headline": "Continuous flow", - "subtitle": "One session, many utterances — no app switching.", - }, - { - "id": "03-on-device-asr", - "headline": "On-device speech", - "subtitle": "Audio never leaves your iPhone.", - }, - { - "id": "04-llm-polish", - "headline": "AI-polished output", - "subtitle": "Punctuation, structure, grammar — choose your LLM.", - }, - { - "id": "05-providers", - "headline": "Bring your own key", - "subtitle": "OpenAI, DeepSeek, Qwen, Moonshot, Zhipu, or self-hosted.", - }, -] - -PALETTE = { - "bg_top": (15, 17, 26), - "bg_bot": (28, 32, 48), - "accent": (110, 168, 254), - "text": (245, 246, 250), - "subtext": (170, 178, 196), - "card": (38, 43, 60), - "divider": (62, 70, 92), -} - -def get_font(size, bold=False): - candidates = [ - "/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf", - "/System/Library/Fonts/Helvetica.ttc", - "/Library/Fonts/Arial.ttf", - ] - for path in candidates: - if os.path.exists(path): - try: - return ImageFont.truetype(path, size) - except Exception: - continue - return ImageFont.load_default() - -def draw_dashed(draw, x1, y1, x2, y2, dash=14, gap=10, fill=PALETTE["divider"], width=2): - if y1 == y2: - x = x1 - while x < x2: - draw.line([(x, y1), (min(x + dash, x2), y1)], fill=fill, width=width) - x += dash + gap - else: - y = y1 - while y < y2: - draw.line([(x1, y), (x1, y + min(dash, y2 - y))], fill=fill, width=width) - y += dash + gap - -def render(path, width, height, screen): - img = Image.new("RGB", (width, height), PALETTE["bg_top"]) - draw = ImageDraw.Draw(img) - # vertical gradient - for y in range(height): - t = y / height - r = int(PALETTE["bg_top"][0] * (1 - t) + PALETTE["bg_bot"][0] * t) - g = int(PALETTE["bg_top"][1] * (1 - t) + PALETTE["bg_bot"][1] * t) - b = int(PALETTE["bg_top"][2] * (1 - t) + PALETTE["bg_bot"][2] * t) - draw.line([(0, y), (width, y)], fill=(r, g, b)) - - # status bar mock - status_y = int(height * 0.02) - draw.text((width * 0.06, status_y), "9:41", - font=get_font(int(height * 0.022), bold=True), fill=PALETTE["text"]) - draw.text((width * 0.86, status_y), "5G 100%", - font=get_font(int(height * 0.018)), fill=PALETTE["subtext"]) - - # status bar divider - draw_dashed(draw, width * 0.05, status_y + height * 0.045, width * 0.95, status_y + height * 0.045) - - # big rounded "phone" mock card - card_pad = int(width * 0.07) - card_top = int(height * 0.10) - card_bot = int(height * 0.88) - card = (card_pad, card_top, width - card_pad, card_bot) - radius = int(width * 0.07) - draw.rounded_rectangle(card, radius=radius, fill=PALETTE["card"], outline=PALETTE["divider"], width=2) - - # mock keyboard hint - kbd_h = int((card_bot - card_top) * 0.22) - kbd_y0 = card_bot - kbd_h - int(width * 0.04) - kbd = (card[0] + int(width * 0.03), kbd_y0, card[2] - int(width * 0.03), kbd_y0 + kbd_h) - draw.rounded_rectangle(kbd, radius=int(width * 0.02), fill=PALETTE["bg_top"], outline=PALETTE["divider"], width=2) - # mic dot - mic_cx = (kbd[0] + kbd[2]) // 2 - mic_cy = (kbd[1] + kbd[3]) // 2 - mic_r = int(kbd_h * 0.30) - draw.ellipse((mic_cx - mic_r, mic_cy - mic_r, mic_cx + mic_r, mic_cy + mic_r), - fill=PALETTE["accent"]) - - # headline + subtitle - headline_y = int(height * 0.18) - draw.text((width // 2, headline_y), screen["headline"], - font=get_font(int(height * 0.055), bold=True), fill=PALETTE["text"], anchor="mm") - sub_y = headline_y + int(height * 0.07) - # wrap subtitle - words = screen["subtitle"].split() - line = "" - sub_font = get_font(int(height * 0.028)) - max_sub_w = int(width * 0.78) - lines = [] - for w in words: - test = (line + " " + w).strip() - if draw.textlength(test, font=sub_font) > max_sub_w: - lines.append(line) - line = w - else: - line = test - if line: - lines.append(line) - for i, l in enumerate(lines): - draw.text((width // 2, sub_y + i * int(height * 0.04)), - l, font=sub_font, fill=PALETTE["subtext"], anchor="mm") - - # watermark - draw.text((width // 2, int(height * 0.965)), - "OSGKeyboard · screenshot placeholder", - font=get_font(int(height * 0.014)), fill=PALETTE["subtext"], anchor="mm") - - img.save(path, "PNG", optimize=True) - return path - -def main(): - sizes = { - "6.7": (1290, 2796), - "6.1": (1179, 2556), - } - total = 0 - for size_id, (w, h) in sizes.items(): - out_dir = os.path.join(OUT_DIR, size_id) - os.makedirs(out_dir, exist_ok=True) - for s in SCREENS: - path = os.path.join(out_dir, f"{s['id']}.png") - render(path, w, h, s) - print(f" wrote {path} ({w}×{h})") - total += 1 - print(f"\nDone. {total} placeholders written under {OUT_DIR}") - print("REPLACE these with real Simulator screenshots before App Store upload.") - -if __name__ == "__main__": - sys.exit(main())