diff --git a/Shared/KBAuthManager.m b/Shared/KBAuthManager.m index 034613d..dca7319 100644 --- a/Shared/KBAuthManager.m +++ b/Shared/KBAuthManager.m @@ -62,6 +62,15 @@ static const NSTimeInterval kKBExpiryGrace = 5.0; // 秒 return m; } +#if DEBUG +static inline void KBLog(NSString *fmt, ...) { + va_list args; va_start(args, fmt); + NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args]; + va_end(args); + NSLog(@"[KBAuth] %@", msg); +} +#endif + - (instancetype)init { if (self = [super init]) { [self reloadFromKeychain]; @@ -166,6 +175,13 @@ static void KBAuthDarwinCallback(CFNotificationCenterRef center, void *observer, query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemAdd failed status=%ld group=%@", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemAdd ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif return (status == errSecSuccess); } @@ -176,6 +192,13 @@ static void KBAuthDarwinCallback(CFNotificationCenterRef center, void *observer, CFTypeRef dataRef = NULL; OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &dataRef); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemCopyMatching status=%ld group=%@ (item not found or no entitlement)", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemCopyMatching ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif if (status != errSecSuccess || !dataRef) return nil; return (__bridge_transfer NSData *)dataRef; } diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 6f4f0e7..b973b2a 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -847,6 +847,10 @@ DEVELOPMENT_TEAM = TN6HHV45BB; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_PREFIX_HEADER = CustomKeyboard/PrefixHeader.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "KB_KEYCHAIN_ACCESS_GROUP=@\\\"$(AppIdentifierPrefix)com.keyBoardst.shared\\\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CustomKeyboard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "我的输入法"; @@ -876,6 +880,10 @@ DEVELOPMENT_TEAM = TN6HHV45BB; ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_PREFIX_HEADER = CustomKeyboard/PrefixHeader.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "KB_KEYCHAIN_ACCESS_GROUP=@\\\"$(AppIdentifierPrefix)com.keyBoardst.shared\\\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CustomKeyboard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "我的输入法"; @@ -906,6 +914,10 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = TN6HHV45BB; GCC_PREFIX_HEADER = keyBoard/KeyBoardPrefixHeader.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "KB_KEYCHAIN_ACCESS_GROUP=@\\\"$(AppIdentifierPrefix)com.keyBoardst.shared\\\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = keyBoard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "YOLO输入法"; @@ -940,6 +952,10 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = TN6HHV45BB; GCC_PREFIX_HEADER = keyBoard/KeyBoardPrefixHeader.pch; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "KB_KEYCHAIN_ACCESS_GROUP=@\\\"$(AppIdentifierPrefix)com.keyBoardst.shared\\\"", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = keyBoard/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "YOLO输入法"; diff --git a/keyBoard/Class/Main/VC/BaseTabBarController.m b/keyBoard/Class/Main/VC/BaseTabBarController.m index dd37e9d..68bd70b 100644 --- a/keyBoard/Class/Main/VC/BaseTabBarController.m +++ b/keyBoard/Class/Main/VC/BaseTabBarController.m @@ -9,7 +9,7 @@ #import "HomeVC.h" #import "MyVC.h" #import "BaseNavigationController.h" - +#import "KBAuthManager.h" @interface BaseTabBarController () @end @@ -30,6 +30,8 @@ navMy.tabBarItem = [[UITabBarItem alloc] initWithTitle:@"我的" image:nil selectedImage:nil]; self.viewControllers = @[navHome, navMy]; + + [[KBAuthManager shared] saveAccessToken:@"TEST" refreshToken:nil expiryDate:[NSDate dateWithTimeIntervalSinceNow:3600] userIdentifier:nil]; } /* diff --git a/keyBoard/Class/Shared/KBAuthManager.h b/keyBoard/Class/Shared/KBAuthManager.h new file mode 100644 index 0000000..b43bf2b --- /dev/null +++ b/keyBoard/Class/Shared/KBAuthManager.h @@ -0,0 +1,56 @@ +// +// KBAuthManager.h +// 主 App 与键盘扩展共享使用 +// +// 通过 Keychain Sharing 统一管理用户登录态(access/refresh token)。 +// 线程安全;在保存/清空时同时发送进程内通知与 Darwin 跨进程通知, +// 以便键盘扩展正运行在其他 App 时也能及时感知变更。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Darwin 跨进程通知名称:当令牌更新或清除时发送,用于提示 App/扩展刷新缓存。 +extern NSString * const kKBDarwinAuthChanged; + +/// 进程内通知(NSNotificationCenter):令牌更新或清除时发送。 +extern NSNotificationName const KBAuthChangedNotification; + +/// 简单的会话容器;可按需扩展字段。 +@interface KBAuthSession : NSObject +@property (nonatomic, copy, nullable) NSString *accessToken; +@property (nonatomic, copy, nullable) NSString *refreshToken; +@property (nonatomic, strong, nullable) NSDate *expiryDate; // 可选:过期时间 +@property (nonatomic, copy, nullable) NSString *userIdentifier; // 可选:如“用 Apple 登录”的 userIdentifier +@end + +/// 基于“共享钥匙串”的鉴权管理器(使用 Keychain Sharing 访问组)。 +@interface KBAuthManager : NSObject + ++ (instancetype)shared; + +/// 当前会话(内存缓存),在加载/保存/清除后更新。 +@property (atomic, strong, readonly, nullable) KBAuthSession *current; + +/// 是否已登录:存在 accessToken 且未明显过期(未设置过期时间则只要有 token 即视为已登录)。 +- (BOOL)isLoggedIn; + +/// 从钥匙串加载到内存;通常首次访问时会自动加载。 +- (void)reloadFromKeychain; + +/// 保存令牌到“共享钥匙串”并通知观察者。 +- (BOOL)saveAccessToken:(NSString *)accessToken + refreshToken:(nullable NSString *)refreshToken + expiryDate:(nullable NSDate *)expiryDate + userIdentifier:(nullable NSString *)userIdentifier; + +/// 从钥匙串与内存中清除令牌,并通知观察者。 +- (void)signOut; + +/// 便捷方法:若存在有效令牌,返回 `Authorization` 请求头;否则返回空字典。 +- (NSDictionary *)authorizationHeader; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/Shared/KBAuthManager.m b/keyBoard/Class/Shared/KBAuthManager.m new file mode 100644 index 0000000..dca7319 --- /dev/null +++ b/keyBoard/Class/Shared/KBAuthManager.m @@ -0,0 +1,211 @@ +// +// KBAuthManager.m +// +// 关键点: +// - 使用固定的 service/account 将 KBAuthSession 序列化后保存到钥匙串; +// - 通过 kSecAttrAccessGroup 指定 Keychain Sharing 访问组,实现 App 与扩展共享; +// - 保存/清除时发送 Darwin 跨进程通知,便于对端刷新缓存; +// + +#import "KBAuthManager.h" +#import +#import "KBConfig.h" // 需要共享钥匙串访问组常量,见 KBConfig.h 中的说明 + +NSString * const kKBDarwinAuthChanged = @"com.keyBoardst.auth.changed"; +NSNotificationName const KBAuthChangedNotification = @"KBAuthChangedNotification"; + +static NSString * const kKBKCService = @"com.keyBoardst.auth"; // 钥匙串 service 名 +static NSString * const kKBKCAccount = @"session"; // 钥匙串 account 键 + +// 用于 Keychain Sharing 的访问组;必须与两个 target 的 entitlements 配置一致。 +// 示例(Capabilities 中勾选 Keychain Sharing 后的值): +// $(AppIdentifierPrefix)com.keyBoardst.shared +// 运行时会被展开为:TN6HHV45BB.com.keyBoardst.shared +#ifndef KB_KEYCHAIN_ACCESS_GROUP +#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.keyBoardst.shared" +#endif + +// 过期宽限:若过期时间距离当前 <= 该阈值,则视为已过期。 +static const NSTimeInterval kKBExpiryGrace = 5.0; // 秒 + +@implementation KBAuthSession + ++ (BOOL)supportsSecureCoding { return YES; } + +- (void)encodeWithCoder:(NSCoder *)coder { + [coder encodeObject:self.accessToken forKey:@"accessToken"]; + [coder encodeObject:self.refreshToken forKey:@"refreshToken"]; + [coder encodeObject:self.expiryDate forKey:@"expiryDate"]; + [coder encodeObject:self.userIdentifier forKey:@"userIdentifier"]; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + if (self = [super init]) { + _accessToken = [coder decodeObjectOfClass:NSString.class forKey:@"accessToken"]; + _refreshToken = [coder decodeObjectOfClass:NSString.class forKey:@"refreshToken"]; + _expiryDate = [coder decodeObjectOfClass:NSDate.class forKey:@"expiryDate"]; + _userIdentifier = [coder decodeObjectOfClass:NSString.class forKey:@"userIdentifier"]; + } + return self; +} + +@end + +@interface KBAuthManager () +@property (atomic, strong, readwrite, nullable) KBAuthSession *current; +@end + +@implementation KBAuthManager + ++ (instancetype)shared { + static KBAuthManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBAuthManager new]; }); + return m; +} + +#if DEBUG +static inline void KBLog(NSString *fmt, ...) { + va_list args; va_start(args, fmt); + NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args]; + va_end(args); + NSLog(@"[KBAuth] %@", msg); +} +#endif + +- (instancetype)init { + if (self = [super init]) { + [self reloadFromKeychain]; + // 监听 Darwin 跨进程通知(App 与扩展之间) + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + KBAuthDarwinCallback, + (__bridge CFStringRef)kKBDarwinAuthChanged, + NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + } + return self; +} + +static void KBAuthDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { + KBAuthManager *self = (__bridge KBAuthManager *)observer; + [self reloadFromKeychain]; +} + +- (void)dealloc { + CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL); +} + +- (BOOL)isLoggedIn { + KBAuthSession *s = self.current; + if (s.accessToken.length == 0) return NO; + if (!s.expiryDate) return YES; // 未设置过期时间时,只要有 token 即视为已登录 + return ([s.expiryDate timeIntervalSinceNow] > kKBExpiryGrace); +} + +#pragma mark - Public + +- (void)reloadFromKeychain { + NSData *data = [self keychainRead]; + KBAuthSession *session = nil; + if (data.length > 0) { + @try { + session = [NSKeyedUnarchiver unarchivedObjectOfClass:KBAuthSession.class fromData:data error:NULL]; + } @catch (__unused NSException *e) { session = nil; } + } + self.current = session; + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; // 进程内通知 +} + +- (BOOL)saveAccessToken:(NSString *)accessToken + refreshToken:(NSString *)refreshToken + expiryDate:(NSDate *)expiryDate + userIdentifier:(NSString *)userIdentifier { + KBAuthSession *s = [KBAuthSession new]; + s.accessToken = accessToken ?: @""; + s.refreshToken = refreshToken; + s.expiryDate = expiryDate; + s.userIdentifier = userIdentifier; + + NSError *err = nil; + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:s requiringSecureCoding:YES error:&err]; + if (err || data.length == 0) return NO; + + BOOL ok = [self keychainWrite:data]; + if (ok) { + self.current = s; + // 进程内通知 + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; + // 跨进程通知(App <-> 扩展) + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true); + } + return ok; +} + +- (void)signOut { + [self keychainDelete]; + self.current = nil; + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true); +} + +- (NSDictionary *)authorizationHeader { + NSString *t = self.current.accessToken; + if (t.length == 0) return @{}; // 未登录返回空头部 + return @{ @"Authorization": [@"Bearer " stringByAppendingString:t] }; +} + +#pragma mark - Keychain (shared) + +- (NSMutableDictionary *)baseKCQuery { + NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: kKBKCService, + (__bridge id)kSecAttrAccount: kKBKCAccount } mutableCopy]; + // 指定共享访问组(App 与扩展共用同一组) + q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP; + return q; +} + +- (BOOL)keychainWrite:(NSData *)data { + if (!data) return NO; + NSMutableDictionary *query = [self baseKCQuery]; + SecItemDelete((__bridge CFDictionaryRef)query); + + // 设置属性 + query[(__bridge id)kSecValueData] = data; + // 访问控制:设备首次解锁后可读,不随备份迁移 + query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + + OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemAdd failed status=%ld group=%@", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemAdd ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif + return (status == errSecSuccess); +} + +- (NSData *)keychainRead { + NSMutableDictionary *query = [self baseKCQuery]; + query[(__bridge id)kSecReturnData] = @YES; + query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; + + CFTypeRef dataRef = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &dataRef); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemCopyMatching status=%ld group=%@ (item not found or no entitlement)", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemCopyMatching ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif + if (status != errSecSuccess || !dataRef) return nil; + return (__bridge_transfer NSData *)dataRef; +} + +- (void)keychainDelete { + NSDictionary *query = [self baseKCQuery]; + SecItemDelete((__bridge CFDictionaryRef)query); +} + +@end diff --git a/keyBoard/Class/Shared/KBConfig.h b/keyBoard/Class/Shared/KBConfig.h new file mode 100644 index 0000000..5813a63 --- /dev/null +++ b/keyBoard/Class/Shared/KBConfig.h @@ -0,0 +1,33 @@ +// +// KBConfig.h +// 主 App 与键盘扩展共用的配置/宏。 +// +// 在此处修改后,会通过 PCH 被两个 target 同步引用。 +// + +#ifndef KBConfig_h +#define KBConfig_h + +// 基础baseUrl +#ifndef KB_BASE_URL +#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/" +#endif + +// Universal Links 通用链接 +#ifndef KB_UL_BASE +#define KB_UL_BASE @"https://your.domain/ul" +#endif + +#define KB_UL_LOGIN KB_UL_BASE @"/login" +#define KB_UL_SETTINGS KB_UL_BASE @"/settings" + +#endif /* KBConfig_h */ + +// --- 认证/共享钥匙串 配置 --- +// 若已在 Capabilities 中启用 Keychain Sharing,并添加访问组: +// $(AppIdentifierPrefix)com.keyBoardst.shared +// 运行时会展开为:TN6HHV45BB.com.keyBoardst.shared +// KBAuthManager 通过下面的宏定位访问组;如需修改,可在 Build Settings 或前缀头中覆盖该宏。 +#ifndef KB_KEYCHAIN_ACCESS_GROUP +#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.keyBoardst.shared" +#endif diff --git a/keyBoard/Shared/KBAuthManager.h b/keyBoard/Shared/KBAuthManager.h new file mode 100644 index 0000000..b43bf2b --- /dev/null +++ b/keyBoard/Shared/KBAuthManager.h @@ -0,0 +1,56 @@ +// +// KBAuthManager.h +// 主 App 与键盘扩展共享使用 +// +// 通过 Keychain Sharing 统一管理用户登录态(access/refresh token)。 +// 线程安全;在保存/清空时同时发送进程内通知与 Darwin 跨进程通知, +// 以便键盘扩展正运行在其他 App 时也能及时感知变更。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Darwin 跨进程通知名称:当令牌更新或清除时发送,用于提示 App/扩展刷新缓存。 +extern NSString * const kKBDarwinAuthChanged; + +/// 进程内通知(NSNotificationCenter):令牌更新或清除时发送。 +extern NSNotificationName const KBAuthChangedNotification; + +/// 简单的会话容器;可按需扩展字段。 +@interface KBAuthSession : NSObject +@property (nonatomic, copy, nullable) NSString *accessToken; +@property (nonatomic, copy, nullable) NSString *refreshToken; +@property (nonatomic, strong, nullable) NSDate *expiryDate; // 可选:过期时间 +@property (nonatomic, copy, nullable) NSString *userIdentifier; // 可选:如“用 Apple 登录”的 userIdentifier +@end + +/// 基于“共享钥匙串”的鉴权管理器(使用 Keychain Sharing 访问组)。 +@interface KBAuthManager : NSObject + ++ (instancetype)shared; + +/// 当前会话(内存缓存),在加载/保存/清除后更新。 +@property (atomic, strong, readonly, nullable) KBAuthSession *current; + +/// 是否已登录:存在 accessToken 且未明显过期(未设置过期时间则只要有 token 即视为已登录)。 +- (BOOL)isLoggedIn; + +/// 从钥匙串加载到内存;通常首次访问时会自动加载。 +- (void)reloadFromKeychain; + +/// 保存令牌到“共享钥匙串”并通知观察者。 +- (BOOL)saveAccessToken:(NSString *)accessToken + refreshToken:(nullable NSString *)refreshToken + expiryDate:(nullable NSDate *)expiryDate + userIdentifier:(nullable NSString *)userIdentifier; + +/// 从钥匙串与内存中清除令牌,并通知观察者。 +- (void)signOut; + +/// 便捷方法:若存在有效令牌,返回 `Authorization` 请求头;否则返回空字典。 +- (NSDictionary *)authorizationHeader; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Shared/KBAuthManager.m b/keyBoard/Shared/KBAuthManager.m new file mode 100644 index 0000000..dca7319 --- /dev/null +++ b/keyBoard/Shared/KBAuthManager.m @@ -0,0 +1,211 @@ +// +// KBAuthManager.m +// +// 关键点: +// - 使用固定的 service/account 将 KBAuthSession 序列化后保存到钥匙串; +// - 通过 kSecAttrAccessGroup 指定 Keychain Sharing 访问组,实现 App 与扩展共享; +// - 保存/清除时发送 Darwin 跨进程通知,便于对端刷新缓存; +// + +#import "KBAuthManager.h" +#import +#import "KBConfig.h" // 需要共享钥匙串访问组常量,见 KBConfig.h 中的说明 + +NSString * const kKBDarwinAuthChanged = @"com.keyBoardst.auth.changed"; +NSNotificationName const KBAuthChangedNotification = @"KBAuthChangedNotification"; + +static NSString * const kKBKCService = @"com.keyBoardst.auth"; // 钥匙串 service 名 +static NSString * const kKBKCAccount = @"session"; // 钥匙串 account 键 + +// 用于 Keychain Sharing 的访问组;必须与两个 target 的 entitlements 配置一致。 +// 示例(Capabilities 中勾选 Keychain Sharing 后的值): +// $(AppIdentifierPrefix)com.keyBoardst.shared +// 运行时会被展开为:TN6HHV45BB.com.keyBoardst.shared +#ifndef KB_KEYCHAIN_ACCESS_GROUP +#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.keyBoardst.shared" +#endif + +// 过期宽限:若过期时间距离当前 <= 该阈值,则视为已过期。 +static const NSTimeInterval kKBExpiryGrace = 5.0; // 秒 + +@implementation KBAuthSession + ++ (BOOL)supportsSecureCoding { return YES; } + +- (void)encodeWithCoder:(NSCoder *)coder { + [coder encodeObject:self.accessToken forKey:@"accessToken"]; + [coder encodeObject:self.refreshToken forKey:@"refreshToken"]; + [coder encodeObject:self.expiryDate forKey:@"expiryDate"]; + [coder encodeObject:self.userIdentifier forKey:@"userIdentifier"]; +} + +- (instancetype)initWithCoder:(NSCoder *)coder { + if (self = [super init]) { + _accessToken = [coder decodeObjectOfClass:NSString.class forKey:@"accessToken"]; + _refreshToken = [coder decodeObjectOfClass:NSString.class forKey:@"refreshToken"]; + _expiryDate = [coder decodeObjectOfClass:NSDate.class forKey:@"expiryDate"]; + _userIdentifier = [coder decodeObjectOfClass:NSString.class forKey:@"userIdentifier"]; + } + return self; +} + +@end + +@interface KBAuthManager () +@property (atomic, strong, readwrite, nullable) KBAuthSession *current; +@end + +@implementation KBAuthManager + ++ (instancetype)shared { + static KBAuthManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBAuthManager new]; }); + return m; +} + +#if DEBUG +static inline void KBLog(NSString *fmt, ...) { + va_list args; va_start(args, fmt); + NSString *msg = [[NSString alloc] initWithFormat:fmt arguments:args]; + va_end(args); + NSLog(@"[KBAuth] %@", msg); +} +#endif + +- (instancetype)init { + if (self = [super init]) { + [self reloadFromKeychain]; + // 监听 Darwin 跨进程通知(App 与扩展之间) + CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), + (__bridge const void *)(self), + KBAuthDarwinCallback, + (__bridge CFStringRef)kKBDarwinAuthChanged, + NULL, + CFNotificationSuspensionBehaviorDeliverImmediately); + } + return self; +} + +static void KBAuthDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { + KBAuthManager *self = (__bridge KBAuthManager *)observer; + [self reloadFromKeychain]; +} + +- (void)dealloc { + CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL); +} + +- (BOOL)isLoggedIn { + KBAuthSession *s = self.current; + if (s.accessToken.length == 0) return NO; + if (!s.expiryDate) return YES; // 未设置过期时间时,只要有 token 即视为已登录 + return ([s.expiryDate timeIntervalSinceNow] > kKBExpiryGrace); +} + +#pragma mark - Public + +- (void)reloadFromKeychain { + NSData *data = [self keychainRead]; + KBAuthSession *session = nil; + if (data.length > 0) { + @try { + session = [NSKeyedUnarchiver unarchivedObjectOfClass:KBAuthSession.class fromData:data error:NULL]; + } @catch (__unused NSException *e) { session = nil; } + } + self.current = session; + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; // 进程内通知 +} + +- (BOOL)saveAccessToken:(NSString *)accessToken + refreshToken:(NSString *)refreshToken + expiryDate:(NSDate *)expiryDate + userIdentifier:(NSString *)userIdentifier { + KBAuthSession *s = [KBAuthSession new]; + s.accessToken = accessToken ?: @""; + s.refreshToken = refreshToken; + s.expiryDate = expiryDate; + s.userIdentifier = userIdentifier; + + NSError *err = nil; + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:s requiringSecureCoding:YES error:&err]; + if (err || data.length == 0) return NO; + + BOOL ok = [self keychainWrite:data]; + if (ok) { + self.current = s; + // 进程内通知 + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; + // 跨进程通知(App <-> 扩展) + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true); + } + return ok; +} + +- (void)signOut { + [self keychainDelete]; + self.current = nil; + [[NSNotificationCenter defaultCenter] postNotificationName:KBAuthChangedNotification object:nil]; + CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)kKBDarwinAuthChanged, NULL, NULL, true); +} + +- (NSDictionary *)authorizationHeader { + NSString *t = self.current.accessToken; + if (t.length == 0) return @{}; // 未登录返回空头部 + return @{ @"Authorization": [@"Bearer " stringByAppendingString:t] }; +} + +#pragma mark - Keychain (shared) + +- (NSMutableDictionary *)baseKCQuery { + NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, + (__bridge id)kSecAttrService: kKBKCService, + (__bridge id)kSecAttrAccount: kKBKCAccount } mutableCopy]; + // 指定共享访问组(App 与扩展共用同一组) + q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP; + return q; +} + +- (BOOL)keychainWrite:(NSData *)data { + if (!data) return NO; + NSMutableDictionary *query = [self baseKCQuery]; + SecItemDelete((__bridge CFDictionaryRef)query); + + // 设置属性 + query[(__bridge id)kSecValueData] = data; + // 访问控制:设备首次解锁后可读,不随备份迁移 + query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; + + OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemAdd failed status=%ld group=%@", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemAdd ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif + return (status == errSecSuccess); +} + +- (NSData *)keychainRead { + NSMutableDictionary *query = [self baseKCQuery]; + query[(__bridge id)kSecReturnData] = @YES; + query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; + + CFTypeRef dataRef = NULL; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &dataRef); +#if DEBUG + if (status != errSecSuccess) { + KBLog(@"SecItemCopyMatching status=%ld group=%@ (item not found or no entitlement)", (long)status, KB_KEYCHAIN_ACCESS_GROUP); + } else { + KBLog(@"SecItemCopyMatching ok group=%@", KB_KEYCHAIN_ACCESS_GROUP); + } +#endif + if (status != errSecSuccess || !dataRef) return nil; + return (__bridge_transfer NSData *)dataRef; +} + +- (void)keychainDelete { + NSDictionary *query = [self baseKCQuery]; + SecItemDelete((__bridge CFDictionaryRef)query); +} + +@end diff --git a/keyBoard/Shared/KBConfig.h b/keyBoard/Shared/KBConfig.h new file mode 100644 index 0000000..5813a63 --- /dev/null +++ b/keyBoard/Shared/KBConfig.h @@ -0,0 +1,33 @@ +// +// KBConfig.h +// 主 App 与键盘扩展共用的配置/宏。 +// +// 在此处修改后,会通过 PCH 被两个 target 同步引用。 +// + +#ifndef KBConfig_h +#define KBConfig_h + +// 基础baseUrl +#ifndef KB_BASE_URL +#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/" +#endif + +// Universal Links 通用链接 +#ifndef KB_UL_BASE +#define KB_UL_BASE @"https://your.domain/ul" +#endif + +#define KB_UL_LOGIN KB_UL_BASE @"/login" +#define KB_UL_SETTINGS KB_UL_BASE @"/settings" + +#endif /* KBConfig_h */ + +// --- 认证/共享钥匙串 配置 --- +// 若已在 Capabilities 中启用 Keychain Sharing,并添加访问组: +// $(AppIdentifierPrefix)com.keyBoardst.shared +// 运行时会展开为:TN6HHV45BB.com.keyBoardst.shared +// KBAuthManager 通过下面的宏定位访问组;如需修改,可在 Build Settings 或前缀头中覆盖该宏。 +#ifndef KB_KEYCHAIN_ACCESS_GROUP +#define KB_KEYCHAIN_ACCESS_GROUP @"TN6HHV45BB.com.keyBoardst.shared" +#endif