chore: snapshot uncommitted local work before rebasing onto cloud-agent

Safety commit so the 94 uncommitted files in the working tree (in-flight
Account/Mac/Tip work plus the feature-preview recording tooling) survive the
move onto cursor/cloud-agent-1787824905651-vxuwc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:28:07 -07:00
co-authored by Claude Opus 5
parent 39002336c0
commit 5d51c5bf77
101 changed files with 3629 additions and 1354 deletions
+4 -2
View File
@@ -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` 的通讯录名与文本替换。
-20
View File
@@ -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,
@@ -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
}
}
}
@@ -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",
+28 -2
View File
@@ -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."
@@ -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()
}
}
@@ -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()
}
}
@@ -1,4 +1,4 @@
// AccountModels.swift
// AccountCenterModels.swift
// OSGKeyboard · Main App
//
// Account-center domain models and narrow service boundaries. Transport DTOs
@@ -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 }
@@ -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 {
@@ -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))
@@ -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 {
@@ -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[..<index]),
utteranceID: utteranceID
)
try? await sleep(0.04)
}
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
try? await sleep(0.45)
state.aiSession.markAnswerInserted(offersSend: true)
hostDraft = answer
try? await sleep(0.2)
}
private func prepareState() {
@@ -236,6 +390,10 @@ struct ClipboardHistoryDemoView: View {
state.layoutWidth = 390
state.usesIPadLayoutMetrics = false
state.showsSystemGlobeKey = false
// `AIKeyboardView.showsClipboardSkills` gates on this — without it the
// real skill row never appears no matter what the ranker returns.
state.clipboardHistoryEnabled = true
state.clipboardCandidateBarEnabled = true
state.clipboardOverlay = .none
state.clipboardSuggestionText = nil
state.openClipboardPanel = {
@@ -254,10 +412,14 @@ struct ClipboardHistoryDemoView: View {
history.clearAll()
_ = history.ingest(rawText: "订单号 OSG-20260811-8842", changeCount: 3)
_ = history.ingest(rawText: "https://osglab.com", changeCount: 2)
_ = history.ingest(rawText: "明天下午三点会议室见", changeCount: 1)
_ = history.ingest(rawText: Self.incomingMessage, changeCount: 1)
history.reload()
}
/// The message the clip replies to. Copying it is what puts the reply
/// skills in the row, so it has to be the newest clipboard entry.
static let incomingMessage = "方案我看了,明天能不能把预算那一版也发我?"
private func sleep(_ seconds: Double) async throws {
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
}
+18 -2
View File
@@ -21,6 +21,8 @@ struct EditDemoView: View {
@StateObject private var state = KeyboardState()
@State private var scene: Scene = .hint
/// Mirrors the host document for `--preview-fullscreen` recordings.
@State private var hostText: String = EditDemoView.originalText
private var source: EditSessionSource {
let reference = EditableInputReference(
@@ -36,9 +38,21 @@ struct EditDemoView: 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: .notes,
title: "备忘录",
text: hostText
)
} else {
Spacer(minLength: 0)
}
Group {
switch scene {
case .hint:
@@ -144,6 +158,8 @@ struct EditDemoView: View {
withAnimation(.easeInOut(duration: 0.2)) {
state.editSession = .applying(review)
state.lastTranscript = ExtL10n.string("keyboard.edit.status.applying")
// Close the loop: the host document shows the polished result.
hostText = Self.editedText
}
// Stay on applying so the last captured frames are still the real UI.
try? await sleep(2.0)
@@ -0,0 +1,147 @@
// FeaturePreviewHostDocument.swift
// OSGKeyboard · Main App (DEBUG-only)
//
// Fills the empty band above the scripted keyboard demos so a full-screen
// App Store preview can be recorded from them. The What's New workflow crops
// to the keyboard chrome and must keep its blank backdrop, so every demo view
// only shows this when `--preview-fullscreen` is passed.
//
// Visual language deliberately matches `NotesHostDemoView` — the same
// Notes / Messages stand-in the extension-based recordings sit on top of.
#if DEBUG
import OSGKeyboardShared
import SwiftUI
enum FeaturePreviewFlags {
/// Opt into the full-screen host document above the keyboard chrome.
static var isFullscreen: Bool {
ProcessInfo.processInfo.arguments.contains("--preview-fullscreen")
}
}
/// A Notes- or Messages-like document that reacts to the demo timeline.
struct FeaturePreviewHostDocument: View {
enum Kind {
case notes
case messages
}
let kind: Kind
let title: String
/// Text the keyboard has produced so far. Empty renders the placeholder.
let text: String
/// Messages only — the message being replied to.
var incoming: String?
/// Draws the send-confirmed state for the AI clip's closing beat.
var isSent: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 13))
.foregroundStyle(Color(uiColor: .secondaryLabel))
.padding(.horizontal, 4)
switch kind {
case .notes:
notesBody
case .messages:
messagesBody
}
}
.padding(.horizontal, 18)
.padding(.top, 56)
.padding(.bottom, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var notesBody: some View {
VStack(alignment: .leading, spacing: 0) {
if text.isEmpty {
Text(placeholder)
.font(.system(size: 20))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
} else {
HStack(alignment: .top, spacing: 0) {
Text(text)
.font(.system(size: 20))
.foregroundStyle(Color(uiColor: .label))
.multilineTextAlignment(.leading)
caret
}
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
// Text lands in a stable place instead of jumping as it grows.
.animation(.easeOut(duration: 0.2), value: text)
}
private var messagesBody: some View {
VStack(alignment: .leading, spacing: 12) {
Spacer(minLength: 0)
if let incoming, !incoming.isEmpty {
bubble(incoming, outgoing: false)
}
if !text.isEmpty {
bubble(text, outgoing: true)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if isSent {
Text("已发送")
.font(.system(size: 12))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.animation(.easeOut(duration: 0.24), value: text)
.animation(.easeOut(duration: 0.24), value: isSent)
}
private func bubble(_ content: String, outgoing: Bool) -> 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
@@ -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)
@@ -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)
}
}
@@ -98,7 +98,7 @@ public struct UsageStatsCluster<Header: View>: 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<Header: View>: View {
systemImage: "waveform"
)
}
.frame(maxHeight: splitRowMaxHeight)
HStack(spacing: splitItemSpacing) {
splitCell(
title: SharedL10n.string("stat.translation", language: language),
@@ -136,6 +137,7 @@ public struct UsageStatsCluster<Header: View>: View {
action: onOpenDictionary
)
}
.frame(maxHeight: splitRowMaxHeight)
}
}
@@ -164,7 +166,8 @@ public struct UsageStatsCluster<Header: View>: View {
title: title,
value: value,
caption: caption,
accent: accent
accent: accent,
expands: splitCellsFillHeight
)
.overlay(alignment: .topTrailing) {
disclosureIndicator
@@ -173,15 +176,17 @@ public struct UsageStatsCluster<Header: View>: 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<Header: View>: View {
private var splitSectionSpacing: CGFloat {
#if os(macOS)
Spacing.md
Spacing.sm
#else
CardLayoutMetrics.sectionSpacing
#endif
@@ -343,11 +348,24 @@ public struct UsageStatsCluster<Header: View>: 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 {
@@ -80,7 +80,7 @@ public actor AccountAPIClient {
private struct RefreshOperation {
let id: UUID
let failedAccessToken: String
let task: Task<AccountSession, Error>
let task: Task<AccountTokenSession, Error>
}
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<AccountSession>.self, from: data).data
let session = try decode(APIDataEnvelope<AccountTokenSession>.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<AccountSession>.self, from: data).data
return try decode(APIDataEnvelope<AccountTokenSession>.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
@@ -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
@@ -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
}
}
@@ -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)
@@ -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(
@@ -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..<barCount {
let start = barIndex * samplesPerBar
// A tap buffer shorter than `barCount` floors `samplesPerBar` to 1,
// so the tail bars would otherwise start past `frameLength` and
// build a reversed `start..<end` range — an uncatchable trap on the
// realtime audio thread. Clamping `start` leaves those bars empty
// (level 0), which is the right reading for samples we do not have.
let start = min(barIndex * samplesPerBar, frameLength)
let end = min(start + samplesPerBar, frameLength)
var sum: Float = 0
for i in start..<end {
@@ -1,126 +0,0 @@
// TipPurchaseManager.swift
// OSGKeyboard · HostSupport
//
// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only —
// no feature gates, no App Group sync, no restore (Apple consumable rules).
import Combine
import Foundation
import StoreKit
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
public enum TipPurchaseState: Equatable {
case idle
case loading
case purchasing
case succeeded
case failed(String)
}
@MainActor
public final class TipPurchaseManager: ObservableObject {
public static let shared = TipPurchaseManager()
@Published public private(set) var product: Product?
@Published public private(set) var purchaseState: TipPurchaseState = .idle
@Published public private(set) var supportCount: Int
private var transactionUpdatesTask: Task<Void, Never>?
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<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified:
throw TipPurchaseError.failedVerification
}
}
}
private enum TipPurchaseError: Error {
case failedVerification
}
+37 -14
View File
@@ -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)
+2
View File
@@ -46,5 +46,7 @@
<string>OSGKeyboard uses the microphone to transcribe your voice via your configured cloud speech provider.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition for local dictation mode.</string>
<key>OSGPrivateKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)com.osgkeyboard.mac</string>
</dict>
</plist>
+141
View File
@@ -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<ASAuthorization, Error>) {
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)"
)
}
}
}
}
+363
View File
@@ -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<Bool> {
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<Void, Never>?
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
}
}
}
+16 -3
View File
@@ -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
+11 -4
View File
@@ -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)
+18
View File
@@ -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
+170 -83
View File
@@ -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<String>,
keyLabel: String,
key: Binding<String>,
modelLabel: String,
modelPlaceholder: String,
model modelText: Binding<String>,
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<String> {
private var currentPolishProvider: LLMProvider {
viewModel.polishSelectableProviders.first { $0.id == viewModel.config.providerId }
?? viewModel.polishSelectableProviders.first
?? LLMProvider.presets[0]
}
private var polishProviderBinding: Binding<String> {
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<String> {
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() }
}
+24 -35
View File
@@ -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 {
@@ -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)
}
}
@@ -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<Void, Never>?
/// 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<Void, Never>?
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() }
}
}
}
+7 -2
View File
@@ -2,17 +2,22 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
<string>$(AppIdentifierPrefix)com.osgkeyboard.mac</string>
</array>
</dict>
</plist>
+24 -3
View File
@@ -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/<code>). 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)
}
}
@@ -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.
@@ -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] = []
@@ -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..<end)
return significantCharacterCount(residual) <= entityResidualLengthLimit
}
/// Digits belong to the number itself, so only letters count as residual.
private static func isPhoneNumberDominant(_ text: String) -> 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 {
@@ -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<Void, Error>?
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<Void, Error>? {
lock.withLock { () -> CheckedContinuation<Void, Error>? 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<Void, Error>) 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<Data, Error>) 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()
}
@@ -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"
}
+19 -1
View File
@@ -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
}
@@ -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 {
+59 -17
View File
@@ -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.";
+59 -16
View File
@@ -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" = "邀请服务暂时不可用。";
@@ -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(
"私人备忘:我准备周五自己整理完这份报告。"
@@ -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
+107
View File
@@ -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..<end` range is an
// uncatchable Swift trap that takes the whole host app down mid-dictation — so
// the short-buffer cases below are the ones that matter, not the happy path.
import AVFoundation
@testable import OSGKeyboardHostSupport
import XCTest
final class FlowLevelStoreTests: XCTestCase {
/// Mirrors `FlowContinuousCapture.levelBarCount`, which cannot be read here
/// directly: it lives on a `@MainActor` type and this suite is nonisolated.
/// `testBarCountMatchesShippedConstant` keeps the two from drifting.
private let barCount = 24
@MainActor
func testBarCountMatchesShippedConstant() {
XCTAssertEqual(
barCount,
FlowContinuousCapture.levelBarCount,
"Bar count drifted — the short-buffer cases below are sized to it"
)
}
private func makeBuffer(frameLength: Int, amplitude: Float = 0.5) -> 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..<frameLength {
channel[index] = amplitude
}
}
return buffer
}
/// The shipped bar count is 24. Any tap buffer shorter than that used to
/// drive the tail bars past `frameLength` and build a reversed range.
func testShortBuffersDoNotTrap() {
for frameLength in [0, 1, 2, 5, 10, 22, 23, 24, 25] {
let levels = FlowLevelStore.calculateLevels(
from: makeBuffer(frameLength: frameLength),
barCount: barCount
)
XCTAssertEqual(
levels.count,
barCount,
"frameLength=\(frameLength) must still produce a full bar array"
)
for level in levels {
XCTAssertFalse(level.isNaN, "frameLength=\(frameLength) produced NaN")
XCTAssertTrue(
(0...1).contains(level),
"frameLength=\(frameLength) produced out-of-range level \(level)"
)
}
}
}
/// Bars past the available samples read as silence rather than crashing.
func testBarsBeyondAvailableSamplesReadAsZero() {
let levels = FlowLevelStore.calculateLevels(
from: makeBuffer(frameLength: 10),
barCount: barCount
)
XCTAssertEqual(levels.count, barCount)
// 10 samples spread one-per-bar: the first 10 carry signal, the rest are empty.
XCTAssertTrue(levels[0] > 0, "leading bars should carry the signal")
for index in 10..<barCount {
XCTAssertEqual(levels[index], 0, "bar \(index) has no samples and must read 0")
}
}
/// Normal-sized tap buffers keep producing a full, non-silent waveform.
func testTypicalBufferSizesProduceSignal() {
for frameLength in [64, 441, 1_024, 4_096] {
let levels = FlowLevelStore.calculateLevels(
from: makeBuffer(frameLength: frameLength),
barCount: barCount
)
XCTAssertEqual(levels.count, barCount)
XCTAssertTrue(
levels.allSatisfy { $0 > 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)
}
}
@@ -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() {
@@ -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,
-39
View File
@@ -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)
}
}
-1
View File
@@ -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
-1
View File
@@ -78,7 +78,6 @@ iOS / iPadOS 账号中心支持:
- 服务端核验购买结果并维护积分账本
- 键盘扩展只接收短时、限权的托管服务凭证
自愿打赏 `ByRockyACoffee` 与积分包彼此独立,不解锁核心功能。
## 历史、统计与同步
-3
View File
@@ -1,3 +0,0 @@
# OSGKeyboard
中文说明已合并至主文档:[README.md](./README.md)
+206
View File
@@ -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/<slug>.mp4 (+ <slug>-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:]))
+441
View File
@@ -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-<slug>-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/<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:]))
+58
View File
@@ -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,
),
}
+122
View File
@@ -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, "想好了,直接发出去"),
],
),
],
),
}
+111
View File
@@ -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 <UDID> [slug ...]
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
UDID="${1:?usage: record_feature_previews.sh <UDID> [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 <path>.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"
+1 -1
View File
@@ -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"
]
},
+3 -4
View File
@@ -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.
+41 -10
View File
@@ -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
+145
View File
@@ -0,0 +1,145 @@
# 功能预览片录制 / Feature preview recording
四段中文 App Store 预览片的录制与合成流程。成片位于
`docs/assets/app-preview/zh/OSGKeyboard-<slug>-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/<slug>.mp4`,另附 `<slug>-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 之内,不合规会在输出里标 `!!`。
-109
View File
@@ -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.
+98 -80
View File
@@ -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 |
+5
View File
@@ -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
+10
View File
@@ -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 |
@@ -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
---
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.
+4 -3
View File
@@ -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(气泡预览等加分项)未启动。
---
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 真流式识别;
+57
View File
@@ -178,6 +178,63 @@
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
<p class="empty" id="empty-en">No release notes match this app version.</p>
<!-- ========== 2.0.3 ========== -->
<article class="version-block" data-version="2.0.3">
<div class="version-chip">2.0.3</div>
<div class="lang-block" data-lang="zh">
<section class="section">
<h2>英文个性词推荐</h2>
<ul>
<li>除中文输入习惯外,键盘现在也会学习你反复使用的英文生词,并推荐加入个人词库</li>
<li>中英文推荐词都需要你确认后才会加入,原有的中文学习记录不受影响</li>
</ul>
</section>
<section class="section">
<h2>界面一致性优化</h2>
<ul>
<li>统一卡片间距、设置页字体和菜单对齐方式,账号与购买记录的排版更整齐</li>
<li>一级页面与详情页之间的标签栏过渡更自然</li>
</ul>
</section>
<section class="section">
<h2>问题修复</h2>
<ul>
<li>重装 App 或重新回到前台后,Apple 账号登录状态恢复更可靠,登录过程中的加载提示保持一致</li>
<li>在不同 App 之间切换后,中英文候选词可靠恢复,且不会残留上一个输入框的内容</li>
</ul>
</section>
</div>
<div class="lang-block" data-lang="en">
<section class="section">
<h2>English Personal-Term Suggestions</h2>
<ul>
<li>Alongside Chinese typing habits, the keyboard now learns rare English words you use repeatedly and suggests adding them to your Personal Dictionary</li>
<li>Suggestions in either language are added only after you confirm, and existing Chinese learning history is preserved</li>
</ul>
</section>
<section class="section">
<h2>Interface Consistency</h2>
<ul>
<li>Card spacing, settings typography, menu alignment, and the account and purchase-history rows are now consistent</li>
<li>Tab-bar transitions between primary pages and detail pages feel smoother</li>
</ul>
</section>
<section class="section">
<h2>Fixes</h2>
<ul>
<li>Apple sign-in is restored more reliably after a reinstall or when returning to the foreground, with a consistent loading state</li>
<li>Chinese and English candidates recover reliably after switching apps, without leaking state from the previous text field</li>
</ul>
</section>
</div>
</article>
<!-- ========== 2.0.2 ========== -->
<article class="version-block" data-version="2.0.2">
<div class="version-chip">2.0.2</div>
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

-73
View File
@@ -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
+40 -4
View File
@@ -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"

Some files were not shown because too many files have changed in this diff Show More