Files
keyboard/Shared/KBSkinManager.m
2026-03-02 20:20:28 +08:00

578 lines
26 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 "KBConfig.h"
#import <ImageIO/ImageIO.h>
NSString * const KBSkinDidChangeNotification = @"KBSkinDidChangeNotification";
NSString * const KBDarwinSkinChanged = @"com.loveKey.nyx.skin.changed";
// 使用 App Group 里的 NSUserDefaults 持久化皮肤主题(不再依赖 Keychain
static NSString * const kKBSkinThemeStoreKey = @"KBSkinThemeCurrent";
@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"];
// 这两个字段是新增的,旧数据没有也没关系
// iOS 17 开始需要显式声明容器里元素的类型,否则会提示 validateAllowedClass 警告
NSSet *arrayClasses = [NSSet setWithObjects:NSArray.class, NSString.class, nil];
_hiddenKeyTextIdentifiers = [coder decodeObjectOfClasses:arrayClasses forKey:@"hiddenKeyTextIdentifiers"];
NSSet *dictClasses = [NSSet setWithObjects:NSDictionary.class, NSString.class, NSNumber.class, nil];
_keyIconMap = [coder decodeObjectOfClasses:dictClasses forKey:@"keyIconMap"];
}
return self;
}
@end
@interface KBSkinManager ()
@property (atomic, strong, readwrite) KBSkinTheme *current;
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *kb_fileImageCache;
@property (nonatomic, copy, nullable) NSString *kb_cachedBgSkinId;
@property (nonatomic, assign) BOOL kb_cachedBgResolved;
@property (nonatomic, strong, nullable) UIImage *kb_cachedBgImage;
@end
@implementation KBSkinManager
/// 从文件路径解码图片,并按 maxPixel 限制最长边像素(避免加载超大背景图导致键盘扩展内存飙升)。
+ (nullable UIImage *)kb_imageAtPath:(NSString *)path maxPixel:(NSUInteger)maxPixel {
if (path.length == 0) return nil;
NSURL *url = [NSURL fileURLWithPath:path];
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL);
if (!source) return nil;
NSDictionary *opts = @{
(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES,
(__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES,
(__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(MAX(1, (NSInteger)maxPixel)),
};
CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts);
CFRelease(source);
if (!cg) return nil;
UIImage *img = [UIImage imageWithCGImage:cg scale:[UIScreen mainScreen].scale orientation:UIImageOrientationUp];
CGImageRelease(cg);
return img;
}
static inline NSUInteger KBApproxImageCostBytes(UIImage *img) {
if (!img) return 0;
CGFloat scale = img.scale > 0 ? img.scale : [UIScreen mainScreen].scale;
CGSize s = img.size;
double px = (double)s.width * scale * (double)s.height * scale;
if (px <= 0) return 0;
// RGBA 4 bytes/pixel
double cost = px * 4.0;
if (cost > (double)NSUIntegerMax) return NSUIntegerMax;
return (NSUInteger)cost;
}
/// 返回所有可能的皮肤根目录(优先 App Group其次当前进程的 Caches
+ (NSArray<NSString *> *)kb_candidateBaseRoots {
NSMutableArray<NSString *> *roots = [NSMutableArray array];
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (containerURL.path.length > 0) {
[roots addObject:containerURL.path];
}
NSArray<NSString *> *dirs = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
NSString *caches = dirs.firstObject;
if (caches.length > 0) {
[roots addObject:caches];
}
return roots;
}
/// 判断指定 skinId 是否有可用资源目录(任一根目录下存在 Skins/<skinId>/...)。
/// 默认皮肤nil / @"default")始终视为存在。
+ (BOOL)kb_hasAssetsForSkinId:(NSString *)skinId {
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
return YES;
}
NSArray<NSString *> *roots = [self kb_candidateBaseRoots];
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *base in roots) {
NSString *skinsRoot = [base stringByAppendingPathComponent:@"Skins"];
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
BOOL isDir = NO;
if ([fm fileExistsAtPath:skinRoot isDirectory:&isDir] && isDir) {
return YES;
}
}
return NO;
}
+ (instancetype)shared {
static KBSkinManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ m = [KBSkinManager new]; });
return m;
}
- (instancetype)init {
if (self = [super init]) {
_kb_fileImageCache = [NSCache new];
// 键盘扩展内存上限较小,缓存要保守一些;主 App 也共用该实现但不会出问题。
// iPad 的键盘背景可能更大,适当放宽。
if (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) {
_kb_fileImageCache.totalCostLimit = 24 * 1024 * 1024;
} else {
_kb_fileImageCache.totalCostLimit = 12 * 1024 * 1024;
}
KBSkinTheme *t = [self p_loadFromStore];
// 若存储中的皮肤在 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_reloadFromStoreAndBroadcast: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;
}
NSUInteger iconCount = [t.keyIconMap isKindOfClass:NSDictionary.class] ? t.keyIconMap.count : 0;
NSUInteger hiddenCount = t.hiddenKeyTextIdentifiers.count;
NSLog(@"[SkinManager] applyThemeFromJSON id=%@ name=%@ iconMap=%tu hiddenKeys=%tu",
t.skinId, t.name, iconCount, hiddenCount);
if (iconCount > 0) {
NSLog(@"[SkinManager] iconMap sample shift=%@ shift_upper=%@ backspace=%@ mode_123=%@ return=%@",
t.keyIconMap[@"shift"],
t.keyIconMap[@"shift_upper"],
t.keyIconMap[@"backspace"],
t.keyIconMap[@"mode_123"],
t.keyIconMap[@"return"]);
}
return [self applyTheme:t];
}
- (BOOL)applyTheme:(KBSkinTheme *)theme {
if (!theme) return NO;
NSLog(@"🎨[SkinManager] apply theme id=%@ name=%@", theme.skinId, theme.name);
[self clearRuntimeImageCaches];
// 将主题写入 App Group 存储(失败也不影响本次进程内的使用)
[self p_saveToStore:theme];
// 始终更新当前主题并广播通知,确保当前进程和扩展之间保持同步。
self.current = theme;
[[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge CFStringRef)KBDarwinSkinChanged,
NULL,
NULL,
true);
return YES;
}
- (void)resetToDefault {
[self applyTheme:[self.class defaultTheme]];
}
- (void)clearRuntimeImageCaches {
@synchronized (self) {
[self.kb_fileImageCache removeAllObjects];
self.kb_cachedBgSkinId = nil;
self.kb_cachedBgResolved = NO;
self.kb_cachedBgImage = nil;
}
}
- (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 {
NSString *skinId = self.current.skinId;
if (skinId.length == 0) return nil;
// 同一个 skinId 在键盘的生命周期内会被频繁读取;缓存一份避免反复解码导致内存上涨。
@synchronized (self) {
if (self.kb_cachedBgResolved && [self.kb_cachedBgSkinId isEqualToString:skinId]) {
return self.kb_cachedBgImage;
}
}
NSArray<NSString *> *roots = [self.class kb_candidateBaseRoots];
NSFileManager *fm = [NSFileManager defaultManager];
NSString *relative = [NSString stringWithFormat:@"Skins/%@/background.png", skinId];
// 背景图通常远大于键盘实际显示区域,按像素上限做缩略解码,显著降低扩展内存占用。
NSUInteger maxPixel = (UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad) ? 2048 : 1024;
for (NSString *base in roots) {
NSString *bgPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
BOOL isDir = NO;
if (![fm fileExistsAtPath:bgPath isDirectory:&isDir] || isDir) {
continue;
}
NSString *cacheKey = [NSString stringWithFormat:@"bg|%@", bgPath];
UIImage *cached = [self.kb_fileImageCache objectForKey:cacheKey];
if (cached) {
@synchronized (self) {
self.kb_cachedBgSkinId = skinId;
self.kb_cachedBgResolved = YES;
self.kb_cachedBgImage = cached;
}
return cached;
}
UIImage *img = [self.class kb_imageAtPath:bgPath maxPixel:maxPixel];
if (img) {
NSUInteger cost = KBApproxImageCostBytes(img);
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:cost];
@synchronized (self) {
self.kb_cachedBgSkinId = skinId;
self.kb_cachedBgResolved = YES;
self.kb_cachedBgImage = img;
}
return img;
}
}
@synchronized (self) {
self.kb_cachedBgSkinId = skinId;
self.kb_cachedBgResolved = YES;
self.kb_cachedBgImage = nil;
}
return 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 {
#if DEBUG
static NSSet<NSString *> *kb_debugIconIds;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
kb_debugIconIds = [NSSet setWithObjects:
@"shift", @"backspace", @"mode_123", @"mode_abc",
@"symbols_toggle_more", @"symbols_toggle_123",
@"return", @"space", @"emoji_panel", @"letter_q",
nil];
});
BOOL shouldLog = [kb_debugIconIds containsObject:identifier];
#endif
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;
}
}
}
// 调试日志:打印未找到映射的按键 ID
if (value.length == 0 && identifier.length > 0) {
static NSMutableSet<NSString *> *kb_loggedMissingIds = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
kb_loggedMissingIds = [NSMutableSet set];
});
if (![kb_loggedMissingIds containsObject:identifier]) {
[kb_loggedMissingIds addObject:identifier];
NSLog(@"[SkinManager] ⚠️ Missing icon mapping: id='%@' skin='%@' mapCount=%lu",
identifier, self.current.skinId ?: @"default", (unsigned long)map.count);
}
}
// 若在 keyIconMap 中找到了 value按约定加载
if (value.length > 0) {
if ([value containsString:@"/"]) {
// 视为相对皮肤根目录App Group / Caches的文件路径。
NSArray<NSString *> *roots = [self.class kb_candidateBaseRoots];
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *base in roots) {
NSString *fullPath = [[base stringByAppendingPathComponent:value] stringByStandardizingPath];
BOOL isDir = NO;
if (![fm fileExistsAtPath:fullPath isDirectory:&isDir] || isDir) {
continue;
}
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
if (img) return img;
img = [UIImage imageWithContentsOfFile:fullPath];
if (img) {
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
}
if (img) return img;
}
#if DEBUG
if (shouldLog) {
NSLog(@"[SkinManager] icon file missing id=%@ value=%@ skin=%@",
identifier, value, self.current.skinId ?: @"");
}
#endif
return nil;
}
// 否则按本地 Assets 名称加载(兼容旧实现)
UIImage *img = [UIImage imageNamed:value];
#if DEBUG
if (!img && shouldLog) {
NSLog(@"[SkinManager] icon asset missing id=%@ value=%@ skin=%@",
identifier, value, self.current.skinId ?: @"");
}
#endif
return img;
}
// 兜底:若 keyIconMap 中没有该键,则按照约定的命名规则直接从 App Group 读取:
// Skins/<skinId>/icons/(identifier[_upper/_lower]).png
NSString *skinId = self.current.skinId;
if (skinId.length == 0 || identifier.length == 0) return nil;
NSArray<NSString *> *roots = [self.class kb_candidateBaseRoots];
NSFileManager *fm = [NSFileManager defaultManager];
// 先尝试大小写后缀
if (caseVariant == 2 || caseVariant == 1) {
NSString *suffix = (caseVariant == 2) ? @"_upper" : @"_lower";
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@%@.png", skinId, identifier, suffix];
for (NSString *base in roots) {
NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
BOOL isDir = NO;
if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) {
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
if (img) return img;
img = [UIImage imageWithContentsOfFile:fullPath];
if (img) {
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
}
if (img) return img;
}
}
}
// 最后回退到基础 idSkins/<skinId>/icons/<identifier>.png
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@.png", skinId, identifier];
for (NSString *base in roots) {
NSString *fullPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
BOOL isDir = NO;
if ([fm fileExistsAtPath:fullPath isDirectory:&isDir] && !isDir) {
NSString *cacheKey = [NSString stringWithFormat:@"icon|%@", fullPath];
UIImage *img = [self.kb_fileImageCache objectForKey:cacheKey];
if (img) return img;
img = [UIImage imageWithContentsOfFile:fullPath];
if (img) {
[self.kb_fileImageCache setObject:img forKey:cacheKey cost:KBApproxImageCostBytes(img)];
}
if (img) return img;
}
}
#if DEBUG
if (shouldLog) {
NSLog(@"[SkinManager] icon fallback missing id=%@ variant=%ld skin=%@",
identifier, (long)caseVariant, self.current.skinId ?: @"");
}
#endif
return nil;
}
+ (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 - Persistence (App Group)
/// 将当前皮肤主题写入 App Group 对应的 NSUserDefaults。
- (BOOL)p_saveToStore:(KBSkinTheme *)theme {
if (!theme) return NO;
NSError *err = nil;
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:theme
requiringSecureCoding:YES
error:&err];
if (err || data.length == 0) return NO;
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
if (!ud) return NO;
[ud setObject:data forKey:kKBSkinThemeStoreKey];
return [ud synchronize];
}
/// 从 App Group 的 NSUserDefaults 读取已存主题。
- (KBSkinTheme *)p_loadFromStore {
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
if (!ud) return nil;
NSData *data = [ud objectForKey:kKBSkinThemeStoreKey];
if (![data isKindOfClass:NSData.class] || 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_reloadFromStoreAndBroadcast:(BOOL)broadcast {
KBSkinTheme *t = [self p_loadFromStore];
if (!t || ![self.class kb_hasAssetsForSkinId:t.skinId]) {
t = [self.class defaultTheme];
}
[self clearRuntimeImageCaches];
self.current = t;
if (broadcast) {
[[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];
}
}
@end