Files
keyboard/Shared/KBSkinManager.m
2025-11-20 15:10:22 +08:00

380 lines
18 KiB
Objective-C
Raw 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
/// 判断指定 skinId 在 App Group 中是否存在资源目录Skins/<skinId>/...)。
/// 默认皮肤nil / @"default")始终视为存在。
+ (BOOL)kb_hasAssetsForSkinId:(NSString *)skinId {
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
return YES;
}
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (!containerURL) return NO;
NSString *skinsRoot = [containerURL.path stringByAppendingPathComponent:@"Skins"];
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
BOOL isDir = NO;
return [[NSFileManager defaultManager] fileExistsAtPath:skinRoot isDirectory:&isDir] && isDir;
}
+ (instancetype)shared {
static KBSkinManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBSkinManager new]; });
return m;
}
- (instancetype)init {
if (self = [super init]) {
KBSkinTheme *t = [self p_loadFromKeychain];
// 若 Keychain 中的皮肤在 App Group 中找不到对应资源目录(如首次安装 / 已被清理),则回退到默认皮肤。
if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) {
t = [self.class defaultTheme];
}
_current = t;
// 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 {
// 仅作为“存在背景图”的标记使用:图像文件本身存放在 App Group 容器
// Skins/<skinId>/background.png 中,这里不再把二进制图片写入 Keychain
// 避免卸载 App 后仍残留旧背景图。
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;
// 不再把背景图二进制存进主题(从而写入 Keychain统一改为按 skinId
// 从 App Group 容器读取 Skins/<skinId>/background.png。
t.backgroundImageData = nil;
return [self applyTheme:t];
}
- (UIImage *)currentBackgroundImage {
// 新策略:始终从 App Group 容器按 skinId 读取背景图文件,
// 确保当 AppGroup 目录被系统清理时,背景图与按键图标一起消失,
// 不再依赖 Keychain 中可能残留的 backgroundImageData。
NSString *skinId = self.current.skinId;
if (skinId.length == 0) return nil;
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (!containerURL) return nil;
NSString *relative = [NSString stringWithFormat:@"Skins/%@/background.png", skinId];
NSString *bgPath = [[containerURL.path stringByAppendingPathComponent:relative] stringByStandardizingPath];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:bgPath isDirectory:&isDir] || isDir) {
return nil;
}
NSData *data = [NSData dataWithContentsOfFile:bgPath];
if (data.length == 0) return nil;
return [UIImage imageWithData:data 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];
if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) {
t = [self.class defaultTheme];
}
self.current = t;
if (broadcast) {
[[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];
}
}
@end