merge: bring local uncommitted work onto the cloud-agent branch
Merges the wip/local-snapshot-20260901 safety commit (94 uncommitted files: in-flight Account/Mac/Tip work, clipboard-semantics improvements, and the feature-preview recording tooling) into the newer cloud-agent line. Conflict resolutions: - docs/*: honour the branch's deliberate removal in eac44da; drop the now dangling docs/archive/README.md that only pointed at the deleted files. - CHANGELOG: both sides describe separate [Unreleased] features; keep both. - ClipboardSkillSemanticRanker: take the branch's reply routing, which retires ranking playfulReply/acceptInvitation/... as separate skills in favour of ordinary/formal/playful variants inside one reply action. Keep the local link/phone/CJK-density work, which is orthogonal and still referenced. - OfficialSkillCatalogTests: keep the local "installed exactly once" assertion; it matches the test's own comment and does not hardcode the default set. - ClipboardSkillSemanticRankerTests: keep both sides' disjoint test functions; retarget the two link/phone cases at the branch's row, which no longer surfaces the retired specialised reply skills. Verified: xcodebuild build succeeds and the 77 ranker/catalog/analyzer tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+4
-2
@@ -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
|
||||
- **Adaptive personal replies**: learn separate dictation and reply preferences from reviewed evidence, offer ordinary, formal, and playful personalized reply choices from one keyboard action, and keep bounded choice feedback only on the device. / **自适应个人回复**:从经过确认的证据中分别学习听写与回复偏好,通过一个键盘回复操作提供普通、正式和轻松趣味三种个性化候选,并仅在本机限量保留选择反馈。
|
||||
- **Test-build ASR corpus export**: export the newest complete personal-style examples through the 2,500-character target as a protected ZIP containing versioned JSON from Settings in Debug and TestFlight builds, while still allowing smaller non-empty corpora. / **测试版 ASR 语料导出**:在 Debug 与 TestFlight 版本的设置中,将最新完整个人风格样本累积至 2,500 字符后导出为受保护的 ZIP(内含版本化 JSON),不足 2,500 字符的非空语料也可导出。
|
||||
@@ -27,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- **Personal style generation**: derive every reviewed prompt through two-stage corpus evidence, apply concrete low-confidence ASR tendencies instead of replacing them with a neutral template, recover wrapped model JSON once, keep one provider configuration and a 45-second budget across both stages, and distinguish cancellation from timeout. / **专属风格生成**:每个待审阅 Prompt 均通过两阶段语料证据生成,并在证据较少时仍应用原始 ASR 中具体的低置信度表达倾向,而非替换为中性模板;同时支持一次模型 JSON 包装恢复,在两阶段固定同一服务配置与 45 秒预算,并区分主动取消和请求超时。
|
||||
- **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 确认弹窗指向所选热词。
|
||||
@@ -114,8 +118,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` 的通讯录名与文本替换。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
-1
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -304,8 +304,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
|
||||
|
||||
@@ -923,9 +927,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,8 +9,31 @@ import Combine
|
||||
import Foundation
|
||||
|
||||
public enum ClipboardSkillSemanticRanker {
|
||||
/// Calibrated against Latin script. `effectiveLength` scales dense scripts
|
||||
/// up to the same information density before comparing.
|
||||
private static let longTextLengthThreshold = 360.0
|
||||
/// Branch-side raw character gate. Kept alongside the density-aware
|
||||
/// `longTextLengthThreshold` because the two are used by different
|
||||
/// call sites: intent routing counts characters, while the summary and
|
||||
/// list heuristics weight CJK density via `effectiveLength`.
|
||||
private static let longTextCharacterThreshold = 360
|
||||
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
|
||||
/// 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],
|
||||
@@ -87,18 +110,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(
|
||||
@@ -179,7 +212,7 @@ public enum ClipboardSkillSemanticRanker {
|
||||
boost(AIClipboardSkillCatalog.summarizeID, 45)
|
||||
}
|
||||
|
||||
if sourceText.count >= longTextCharacterThreshold {
|
||||
if effectiveLength(sourceText) >= longTextLengthThreshold {
|
||||
boost(AIClipboardSkillCatalog.summarizeID, 145)
|
||||
boost(AIClipboardSkillCatalog.saveToNotesID, 85)
|
||||
}
|
||||
@@ -333,8 +366,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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -143,7 +143,6 @@
|
||||
"ai.responseLength.medium" = "Medium";
|
||||
"ai.responseLength.detailed" = "Detailed";
|
||||
|
||||
|
||||
/* v0.3.0: Detected app context labels */
|
||||
"appContext.code" = "Code";
|
||||
"appContext.email" = "Email";
|
||||
@@ -353,6 +352,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…";
|
||||
@@ -392,9 +392,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";
|
||||
@@ -467,17 +470,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.";
|
||||
|
||||
@@ -352,6 +352,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" = "选择文件夹…";
|
||||
@@ -391,9 +392,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" = "下载默认模型";
|
||||
@@ -466,17 +470,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" = "邀请服务暂时不可用。";
|
||||
|
||||
@@ -294,6 +294,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(
|
||||
"私人备忘:我准备周五自己整理完这份报告。"
|
||||
|
||||
@@ -715,6 +715,134 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertNil(store.snapshot)
|
||||
}
|
||||
|
||||
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.replyID,
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
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.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
|
||||
|
||||
@@ -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,20 +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.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.organizeListID,
|
||||
"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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ iOS / iPadOS 账号中心支持:
|
||||
- 服务端核验购买结果并维护积分账本
|
||||
- 键盘扩展只接收短时、限权的托管服务凭证
|
||||
|
||||
自愿打赏 `ByRockyACoffee` 与积分包彼此独立,不解锁核心功能。
|
||||
|
||||
## 历史、统计与同步
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# OSGKeyboard
|
||||
|
||||
中文说明已合并至主文档:[README.md](./README.md)
|
||||
@@ -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:]))
|
||||
Executable
+441
@@ -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:]))
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
@@ -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, "想好了,直接发出去"),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
}
|
||||
Executable
+111
@@ -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"
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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 之内,不合规会在输出里标 `!!`。
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
@@ -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>
|
||||
|
||||
+40
-4
@@ -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"
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
generate_screenshot_placeholders.py
|
||||
|
||||
Generates App Store screenshot placeholders for OSGKeyboard.
|
||||
Apple App Store Connect accepts:
|
||||
- 6.7" (iPhone 17 Pro Max, 17, 16 Pro Max, 16 Plus, 15 Pro Max, 15 Plus) → 1290 × 2796
|
||||
- 6.1" (iPhone 17 Pro, 17, 16 Pro, 16, 15 Pro, 15, 14 Pro) → 1179 × 2556
|
||||
|
||||
We produce 5 of each (Apple requires 3 minimum, accepts up to 10).
|
||||
|
||||
These are INTENTIONALLY bland placeholders — the marketing team should
|
||||
screenshot the actual app running in the iOS 26 Simulator and replace
|
||||
these before final upload. The script only verifies dimensions and
|
||||
labels.
|
||||
|
||||
Run:
|
||||
python3 scripts/generate_screenshot_placeholders.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT_DIR = os.path.join(REPO_ROOT, "docs", "screenshots")
|
||||
|
||||
SCREENS = [
|
||||
{
|
||||
"id": "01-keyboard-default",
|
||||
"headline": "Hold to talk",
|
||||
"subtitle": "Push-to-talk from any app's text field.",
|
||||
},
|
||||
{
|
||||
"id": "02-flow-session",
|
||||
"headline": "Continuous flow",
|
||||
"subtitle": "One session, many utterances — no app switching.",
|
||||
},
|
||||
{
|
||||
"id": "03-on-device-asr",
|
||||
"headline": "On-device speech",
|
||||
"subtitle": "Audio never leaves your iPhone.",
|
||||
},
|
||||
{
|
||||
"id": "04-llm-polish",
|
||||
"headline": "AI-polished output",
|
||||
"subtitle": "Punctuation, structure, grammar — choose your LLM.",
|
||||
},
|
||||
{
|
||||
"id": "05-providers",
|
||||
"headline": "Bring your own key",
|
||||
"subtitle": "OpenAI, DeepSeek, Qwen, Moonshot, Zhipu, or self-hosted.",
|
||||
},
|
||||
]
|
||||
|
||||
PALETTE = {
|
||||
"bg_top": (15, 17, 26),
|
||||
"bg_bot": (28, 32, 48),
|
||||
"accent": (110, 168, 254),
|
||||
"text": (245, 246, 250),
|
||||
"subtext": (170, 178, 196),
|
||||
"card": (38, 43, 60),
|
||||
"divider": (62, 70, 92),
|
||||
}
|
||||
|
||||
def get_font(size, bold=False):
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf" if bold else "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/Library/Fonts/Arial.ttf",
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
def draw_dashed(draw, x1, y1, x2, y2, dash=14, gap=10, fill=PALETTE["divider"], width=2):
|
||||
if y1 == y2:
|
||||
x = x1
|
||||
while x < x2:
|
||||
draw.line([(x, y1), (min(x + dash, x2), y1)], fill=fill, width=width)
|
||||
x += dash + gap
|
||||
else:
|
||||
y = y1
|
||||
while y < y2:
|
||||
draw.line([(x1, y), (x1, y + min(dash, y2 - y))], fill=fill, width=width)
|
||||
y += dash + gap
|
||||
|
||||
def render(path, width, height, screen):
|
||||
img = Image.new("RGB", (width, height), PALETTE["bg_top"])
|
||||
draw = ImageDraw.Draw(img)
|
||||
# vertical gradient
|
||||
for y in range(height):
|
||||
t = y / height
|
||||
r = int(PALETTE["bg_top"][0] * (1 - t) + PALETTE["bg_bot"][0] * t)
|
||||
g = int(PALETTE["bg_top"][1] * (1 - t) + PALETTE["bg_bot"][1] * t)
|
||||
b = int(PALETTE["bg_top"][2] * (1 - t) + PALETTE["bg_bot"][2] * t)
|
||||
draw.line([(0, y), (width, y)], fill=(r, g, b))
|
||||
|
||||
# status bar mock
|
||||
status_y = int(height * 0.02)
|
||||
draw.text((width * 0.06, status_y), "9:41",
|
||||
font=get_font(int(height * 0.022), bold=True), fill=PALETTE["text"])
|
||||
draw.text((width * 0.86, status_y), "5G 100%",
|
||||
font=get_font(int(height * 0.018)), fill=PALETTE["subtext"])
|
||||
|
||||
# status bar divider
|
||||
draw_dashed(draw, width * 0.05, status_y + height * 0.045, width * 0.95, status_y + height * 0.045)
|
||||
|
||||
# big rounded "phone" mock card
|
||||
card_pad = int(width * 0.07)
|
||||
card_top = int(height * 0.10)
|
||||
card_bot = int(height * 0.88)
|
||||
card = (card_pad, card_top, width - card_pad, card_bot)
|
||||
radius = int(width * 0.07)
|
||||
draw.rounded_rectangle(card, radius=radius, fill=PALETTE["card"], outline=PALETTE["divider"], width=2)
|
||||
|
||||
# mock keyboard hint
|
||||
kbd_h = int((card_bot - card_top) * 0.22)
|
||||
kbd_y0 = card_bot - kbd_h - int(width * 0.04)
|
||||
kbd = (card[0] + int(width * 0.03), kbd_y0, card[2] - int(width * 0.03), kbd_y0 + kbd_h)
|
||||
draw.rounded_rectangle(kbd, radius=int(width * 0.02), fill=PALETTE["bg_top"], outline=PALETTE["divider"], width=2)
|
||||
# mic dot
|
||||
mic_cx = (kbd[0] + kbd[2]) // 2
|
||||
mic_cy = (kbd[1] + kbd[3]) // 2
|
||||
mic_r = int(kbd_h * 0.30)
|
||||
draw.ellipse((mic_cx - mic_r, mic_cy - mic_r, mic_cx + mic_r, mic_cy + mic_r),
|
||||
fill=PALETTE["accent"])
|
||||
|
||||
# headline + subtitle
|
||||
headline_y = int(height * 0.18)
|
||||
draw.text((width // 2, headline_y), screen["headline"],
|
||||
font=get_font(int(height * 0.055), bold=True), fill=PALETTE["text"], anchor="mm")
|
||||
sub_y = headline_y + int(height * 0.07)
|
||||
# wrap subtitle
|
||||
words = screen["subtitle"].split()
|
||||
line = ""
|
||||
sub_font = get_font(int(height * 0.028))
|
||||
max_sub_w = int(width * 0.78)
|
||||
lines = []
|
||||
for w in words:
|
||||
test = (line + " " + w).strip()
|
||||
if draw.textlength(test, font=sub_font) > max_sub_w:
|
||||
lines.append(line)
|
||||
line = w
|
||||
else:
|
||||
line = test
|
||||
if line:
|
||||
lines.append(line)
|
||||
for i, l in enumerate(lines):
|
||||
draw.text((width // 2, sub_y + i * int(height * 0.04)),
|
||||
l, font=sub_font, fill=PALETTE["subtext"], anchor="mm")
|
||||
|
||||
# watermark
|
||||
draw.text((width // 2, int(height * 0.965)),
|
||||
"OSGKeyboard · screenshot placeholder",
|
||||
font=get_font(int(height * 0.014)), fill=PALETTE["subtext"], anchor="mm")
|
||||
|
||||
img.save(path, "PNG", optimize=True)
|
||||
return path
|
||||
|
||||
def main():
|
||||
sizes = {
|
||||
"6.7": (1290, 2796),
|
||||
"6.1": (1179, 2556),
|
||||
}
|
||||
total = 0
|
||||
for size_id, (w, h) in sizes.items():
|
||||
out_dir = os.path.join(OUT_DIR, size_id)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
for s in SCREENS:
|
||||
path = os.path.join(out_dir, f"{s['id']}.png")
|
||||
render(path, w, h, s)
|
||||
print(f" wrote {path} ({w}×{h})")
|
||||
total += 1
|
||||
print(f"\nDone. {total} placeholders written under {OUT_DIR}")
|
||||
print("REPLACE these with real Simulator screenshots before App Store upload.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user