410 lines
18 KiB
Objective-C
410 lines
18 KiB
Objective-C
//
|
||
// 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"];
|
||
// 这两个字段是新增的,旧数据没有也没关系
|
||
_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
|
||
|
||
/// 返回所有可能的皮肤根目录(优先 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 最后回退到基础 id:Skins/<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
|