Files
keyboard/Shared/KBSkinManager.m
2025-12-11 16:59:14 +08:00

414 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 "KBConfig.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;
@end
@implementation KBSkinManager
/// 返回所有可能的皮肤根目录(优先 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]) {
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;
}
return [self applyTheme:t];
}
- (BOOL)applyTheme:(KBSkinTheme *)theme {
if (!theme) return NO;
// 将主题写入 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]];
}
- (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;
NSArray<NSString *> *roots = [self.class kb_candidateBaseRoots];
NSFileManager *fm = [NSFileManager defaultManager];
NSString *relative = [NSString stringWithFormat:@"Skins/%@/background.png", skinId];
for (NSString *base in roots) {
NSString *bgPath = [[base stringByAppendingPathComponent:relative] stringByStandardizingPath];
BOOL isDir = NO;
if (![fm fileExistsAtPath:bgPath isDirectory:&isDir] || isDir) {
continue;
}
NSData *data = [NSData dataWithContentsOfFile:bgPath];
if (data.length == 0) continue;
UIImage *img = [UIImage imageWithData:data scale:[UIScreen mainScreen].scale];
if (img) return img;
}
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 {
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 / 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;
}
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
if (img) return img;
}
return nil;
}
// 否则按本地 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;
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) {
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
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) {
UIImage *img = [UIImage imageWithContentsOfFile:fullPath];
if (img) return img;
}
}
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.current = t;
if (broadcast) {
[[NSNotificationCenter defaultCenter] postNotificationName:KBSkinDidChangeNotification object:nil];
}
}
@end