// // KBSkinManager.m // #import "KBSkinManager.h" #import #import "KBConfig.h" NSString * const KBSkinDidChangeNotification = @"KBSkinDidChangeNotification"; NSString * const KBDarwinSkinChanged = @"com.loveKey.nyx.skin.changed"; static NSString * const kKBSkinService = @"com.loveKey.nyx.skin"; // Keychain service static NSString * const kKBSkinAccount = @"current"; // Keychain account @implementation KBSkinTheme + (BOOL)supportsSecureCoding { return YES; } - (void)encodeWithCoder:(NSCoder *)coder { [coder encodeObject:self.skinId forKey:@"skinId"]; [coder encodeObject:self.name forKey:@"name"]; [coder encodeObject:self.keyboardBackground forKey:@"keyboardBackground"]; [coder encodeObject:self.keyBackground forKey:@"keyBackground"]; [coder encodeObject:self.keyTextColor forKey:@"keyTextColor"]; [coder encodeObject:self.keyHighlightBackground forKey:@"keyHighlightBackground"]; [coder encodeObject:self.accentColor forKey:@"accentColor"]; if (self.backgroundImageData) { [coder encodeObject:self.backgroundImageData forKey:@"backgroundImageData"]; } if (self.hiddenKeyTextIdentifiers.count > 0) { [coder encodeObject:self.hiddenKeyTextIdentifiers forKey:@"hiddenKeyTextIdentifiers"]; } if (self.keyIconMap.count > 0) { [coder encodeObject:self.keyIconMap forKey:@"keyIconMap"]; } } - (instancetype)initWithCoder:(NSCoder *)coder { if (self = [super init]) { _skinId = [coder decodeObjectOfClass:NSString.class forKey:@"skinId"] ?: @"default"; _name = [coder decodeObjectOfClass:NSString.class forKey:@"name"] ?: @"Default"; _keyboardBackground = [coder decodeObjectOfClass:UIColor.class forKey:@"keyboardBackground"] ?: [UIColor colorWithWhite:0.95 alpha:1.0]; _keyBackground = [coder decodeObjectOfClass:UIColor.class forKey:@"keyBackground"] ?: UIColor.whiteColor; _keyTextColor = [coder decodeObjectOfClass:UIColor.class forKey:@"keyTextColor"] ?: UIColor.blackColor; _keyHighlightBackground = [coder decodeObjectOfClass:UIColor.class forKey:@"keyHighlightBackground"] ?: [UIColor colorWithWhite:0.85 alpha:1.0]; _accentColor = [coder decodeObjectOfClass:UIColor.class forKey:@"accentColor"] ?: [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0]; _backgroundImageData = [coder decodeObjectOfClass:NSData.class forKey:@"backgroundImageData"]; // 这两个字段是新增的,旧数据没有也没关系 _hiddenKeyTextIdentifiers = [coder decodeObjectOfClass:NSArray.class forKey:@"hiddenKeyTextIdentifiers"]; _keyIconMap = [coder decodeObjectOfClass:NSDictionary.class forKey:@"keyIconMap"]; } return self; } @end @interface KBSkinManager () @property (atomic, strong, readwrite) KBSkinTheme *current; @end @implementation KBSkinManager + (instancetype)shared { static KBSkinManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBSkinManager new]; }); return m; } - (instancetype)init { if (self = [super init]) { _current = [self p_loadFromKeychain] ?: [self.class defaultTheme]; // Observe Darwin notification for cross-process updates CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), KBSkinDarwinCallback, (__bridge CFStringRef)KBDarwinSkinChanged, NULL, CFNotificationSuspensionBehaviorDeliverImmediately); } return self; } static void KBSkinDarwinCallback(CFNotificationCenterRef center, void *observer, CFStringRef name, const void *object, CFDictionaryRef userInfo) { KBSkinManager *self = (__bridge KBSkinManager *)observer; [self p_reloadFromKeychainAndBroadcast:YES]; } - (void)dealloc { CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge const void *)(self), (__bridge CFStringRef)KBDarwinSkinChanged, NULL); } #pragma mark - Public - (BOOL)applyThemeFromJSON:(NSDictionary *)json { if (json.count == 0) return NO; KBSkinTheme *t = [KBSkinTheme new]; t.skinId = [json[@"id"] isKindOfClass:NSString.class] ? json[@"id"] : @"custom"; t.name = [json[@"name"] isKindOfClass:NSString.class] ? json[@"name"] : t.skinId; t.keyboardBackground = [self.class colorFromHexString:json[@"background"] defaultColor:[self.class defaultTheme].keyboardBackground]; t.keyBackground = [self.class colorFromHexString:json[@"key_bg"] defaultColor:[self.class defaultTheme].keyBackground]; t.keyTextColor = [self.class colorFromHexString:json[@"key_text"] defaultColor:[self.class defaultTheme].keyTextColor]; t.keyHighlightBackground = [self.class colorFromHexString:json[@"key_highlight"] defaultColor:[self.class defaultTheme].keyHighlightBackground]; t.accentColor = [self.class colorFromHexString:json[@"accent"] defaultColor:[self.class defaultTheme].accentColor]; // 可选:hidden_keys 为需要隐藏文本的按键标识数组 id hidden = json[@"hidden_keys"]; if ([hidden isKindOfClass:NSArray.class]) { t.hiddenKeyTextIdentifiers = hidden; } // 可选:key_icons 为 按键标识 -> 图标名/文件名 的字典 id icons = json[@"key_icons"]; if ([icons isKindOfClass:NSDictionary.class]) { t.keyIconMap = icons; } return [self applyTheme:t]; } - (BOOL)applyTheme:(KBSkinTheme *)theme { if (!theme) return NO; if ([self p_saveToKeychain:theme]) { self.current = theme; [[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil]; CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(), (__bridge CFStringRef)KBDarwinSkinChanged, NULL, NULL, true); return YES; } return NO; } - (void)resetToDefault { [self applyTheme:[self.class defaultTheme]]; } - (BOOL)applyImageSkinWithData:(NSData *)imageData skinId:(NSString *)skinId name:(NSString *)name { if (imageData.length == 0) return NO; // 构造新主题,继承当前配色作为按键/强调色的默认值 KBSkinTheme *base = self.current ?: [self.class defaultTheme]; KBSkinTheme *t = [KBSkinTheme new]; t.skinId = skinId ?: @"image"; t.name = name ?: t.skinId; t.keyboardBackground = base.keyboardBackground ?: [self.class defaultTheme].keyboardBackground; t.keyBackground = base.keyBackground ?: [self.class defaultTheme].keyBackground; t.keyTextColor = base.keyTextColor ?: [self.class defaultTheme].keyTextColor; t.keyHighlightBackground = base.keyHighlightBackground ?: [self.class defaultTheme].keyHighlightBackground; t.accentColor = base.accentColor ?: [self.class defaultTheme].accentColor; // 继承按键文本隐藏配置和图标映射,避免仅切换背景时丢失这些设置 t.hiddenKeyTextIdentifiers = base.hiddenKeyTextIdentifiers; t.keyIconMap = base.keyIconMap; t.backgroundImageData = imageData; return [self applyTheme:t]; } - (UIImage *)currentBackgroundImage { NSData *d = self.current.backgroundImageData; if (d.length == 0) return nil; return [UIImage imageWithData:d scale:[UIScreen mainScreen].scale] ?: nil; } - (BOOL)shouldHideKeyTextForIdentifier:(NSString *)identifier { if (identifier.length == 0) return NO; NSArray *list = self.current.hiddenKeyTextIdentifiers; if (list.count == 0) return NO; // 简单线性查找,数量有限足够;如需可改为 NSSet 缓存。 for (NSString *s in list) { if ([s isKindOfClass:NSString.class] && [s isEqualToString:identifier]) { return YES; } } return NO; } - (NSString *)iconNameForKeyIdentifier:(NSString *)identifier { if (identifier.length == 0) return nil; NSDictionary *map = self.current.keyIconMap; if (map.count == 0) return nil; NSString *name = map[identifier]; if (![name isKindOfClass:NSString.class] || name.length == 0) return nil; return name; } - (UIImage *)iconImageForKeyIdentifier:(NSString *)identifier { return [self iconImageForKeyIdentifier:identifier caseVariant:0]; } - (UIImage *)iconImageForKeyIdentifier:(NSString *)identifier caseVariant:(NSInteger)caseVariant { NSDictionary *map = self.current.keyIconMap; NSString *value = nil; if (identifier.length > 0 && map.count > 0) { // 1) 大小写变体优先:letter_q_upper / letter_q_lower if (caseVariant == 2) { // upper NSString *keyUpper = [identifier stringByAppendingString:@"_upper"]; NSString *candidate = map[keyUpper]; if ([candidate isKindOfClass:NSString.class] && candidate.length > 0) { value = candidate; } } else if (caseVariant == 1) { // lower NSString *keyLower = [identifier stringByAppendingString:@"_lower"]; NSString *candidate = map[keyLower]; if ([candidate isKindOfClass:NSString.class] && candidate.length > 0) { value = candidate; } } // 2) 若未配置大小写专用图标,则回退到基础 id(兼容旧数据:letter_q) if (value.length == 0) { NSString *candidate = map[identifier]; if ([candidate isKindOfClass:NSString.class] && candidate.length > 0) { value = candidate; } } } // 若在 keyIconMap 中找到了 value,按约定加载 if (value.length > 0) { if ([value containsString:@"/"]) { // 视为相对 App Group 根目录的文件路径 NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (!containerURL) return nil; NSString *fullPath = [[containerURL.path stringByAppendingPathComponent:value] stringByStandardizingPath]; if (![[NSFileManager defaultManager] fileExistsAtPath:fullPath]) return nil; return [UIImage imageWithContentsOfFile:fullPath]; } // 否则按本地 Assets 名称加载(兼容旧实现) return [UIImage imageNamed:value]; } // 兜底:若 keyIconMap 中没有该键,则按照约定的命名规则直接从 App Group 读取: // Skins//icons/(identifier[_upper/_lower]).png NSString *skinId = self.current.skinId; if (skinId.length == 0 || identifier.length == 0) return nil; NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup]; if (!containerURL) return nil; // 先尝试大小写后缀 if (caseVariant == 2) { NSString *relativeUpper = [NSString stringWithFormat:@"Skins/%@/icons/%@_upper.png", skinId, identifier]; NSString *fullUpper = [[containerURL.path stringByAppendingPathComponent:relativeUpper] stringByStandardizingPath]; if ([[NSFileManager defaultManager] fileExistsAtPath:fullUpper]) { return [UIImage imageWithContentsOfFile:fullUpper]; } } else if (caseVariant == 1) { NSString *relativeLower = [NSString stringWithFormat:@"Skins/%@/icons/%@_lower.png", skinId, identifier]; NSString *fullLower = [[containerURL.path stringByAppendingPathComponent:relativeLower] stringByStandardizingPath]; if ([[NSFileManager defaultManager] fileExistsAtPath:fullLower]) { return [UIImage imageWithContentsOfFile:fullLower]; } } // 最后回退到基础 id:Skins//icons/.png NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@.png", skinId, identifier]; NSString *fullPath = [[containerURL.path stringByAppendingPathComponent:relative] stringByStandardizingPath]; if (![[NSFileManager defaultManager] fileExistsAtPath:fullPath]) return nil; return [UIImage imageWithContentsOfFile:fullPath]; } + (UIColor *)colorFromHexString:(NSString *)hex defaultColor:(UIColor *)fallback { if (![hex isKindOfClass:NSString.class] || hex.length == 0) return fallback; NSString *s = [[hex stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString]; if ([s hasPrefix:@"#"]) s = [s substringFromIndex:1]; unsigned long long v = 0; NSScanner *scanner = [NSScanner scannerWithString:s]; if (![scanner scanHexLongLong:&v]) return fallback; if (s.length == 6) { // RRGGBB CGFloat r = ((v >> 16) & 0xFF) / 255.0; CGFloat g = ((v >> 8) & 0xFF) / 255.0; CGFloat b = (v & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:1.0]; } else if (s.length == 8) { // RRGGBBAA CGFloat r = ((v >> 24) & 0xFF) / 255.0; CGFloat g = ((v >> 16) & 0xFF) / 255.0; CGFloat b = ((v >> 8) & 0xFF) / 255.0; CGFloat a = (v & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; } return fallback; } #pragma mark - Defaults + (KBSkinTheme *)defaultTheme { KBSkinTheme *t = [KBSkinTheme new]; t.skinId = @"default"; t.name = @"Default"; t.keyboardBackground = [UIColor colorWithWhite:0.95 alpha:1.0]; t.keyBackground = UIColor.whiteColor; t.keyTextColor = UIColor.blackColor; t.keyHighlightBackground = [UIColor colorWithWhite:0.85 alpha:1.0]; t.accentColor = [UIColor colorWithRed:0.77 green:0.93 blue:0.82 alpha:1.0]; t.backgroundImageData = nil; return t; } #pragma mark - Keychain - (NSMutableDictionary *)baseKCQuery { NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, (__bridge id)kSecAttrService: kKBSkinService, (__bridge id)kSecAttrAccount: kKBSkinAccount } mutableCopy]; q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP; return q; } - (BOOL)p_saveToKeychain:(KBSkinTheme *)theme { NSError *err = nil; NSData *data = [NSKeyedArchiver archivedDataWithRootObject:theme requiringSecureCoding:YES error:&err]; if (err || data.length == 0) return NO; NSMutableDictionary *q = [self baseKCQuery]; SecItemDelete((__bridge CFDictionaryRef)q); q[(__bridge id)kSecValueData] = data; q[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly; OSStatus st = SecItemAdd((__bridge CFDictionaryRef)q, NULL); return (st == errSecSuccess); } - (KBSkinTheme *)p_loadFromKeychain { NSMutableDictionary *q = [self baseKCQuery]; q[(__bridge id)kSecReturnData] = @YES; q[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne; CFTypeRef dataRef = NULL; OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)q, &dataRef); if (st != errSecSuccess || !dataRef) return nil; NSData *data = (__bridge_transfer NSData *)dataRef; if (data.length == 0) return nil; @try { KBSkinTheme *t = [NSKeyedUnarchiver unarchivedObjectOfClass:KBSkinTheme.class fromData:data error:NULL]; return t; } @catch (__unused NSException *e) { return nil; } } - (void)p_reloadFromKeychainAndBroadcast:(BOOL)broadcast { KBSkinTheme *t = [self p_loadFromKeychain] ?: [self.class defaultTheme]; self.current = t; if (broadcast) { [[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil]; } } @end