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:
@@ -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
|
||||
Reference in New Issue
Block a user