Files
keyboard/Shared/KBSkinManager.m

334 lines
15 KiB
Mathematica
Raw Normal View History

2025-11-04 21:01:46 +08:00
//
// 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"];
}
2025-11-18 20:53:47 +08:00
if (self.hiddenKeyTextIdentifiers.count > 0) {
[coder encodeObject:self.hiddenKeyTextIdentifiers forKey:@"hiddenKeyTextIdentifiers"];
}
if (self.keyIconMap.count > 0) {
[coder encodeObject:self.keyIconMap forKey:@"keyIconMap"];
}
2025-11-04 21:01:46 +08:00
}
- (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"];
2025-11-18 20:53:47 +08:00
//
_hiddenKeyTextIdentifiers = [coder decodeObjectOfClass:NSArray.class forKey:@"hiddenKeyTextIdentifiers"];
_keyIconMap = [coder decodeObjectOfClass:NSDictionary.class forKey:@"keyIconMap"];
2025-11-04 21:01:46 +08:00
}
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];
2025-11-18 20:53:47 +08:00
// 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;
}
2025-11-04 21:01:46 +08:00
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;
2025-11-18 20:53:47 +08:00
//
t.hiddenKeyTextIdentifiers = base.hiddenKeyTextIdentifiers;
t.keyIconMap = base.keyIconMap;
2025-11-04 21:01:46 +08:00
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;
}
2025-11-18 20:53:47 +08:00
- (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) 退 idletter_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];
}
2025-11-04 21:01:46 +08:00
+ (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