Files
keyboard/Shared/KBSkinManager.m
2025-11-18 20:53:47 +08:00

334 lines
15 KiB
Objective-C
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// KBSkinManager.m
//
#import "KBSkinManager.h"
#import <Security/Security.h>
#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<NSString *> *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<NSString *, NSString *> *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<NSString *, NSString *> *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/<skinId>/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];
}
}
// 最后回退到基础 idSkins/<skinId>/icons/<identifier>.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