Files
keyboard/keyBoard/Class/Manager/KBSkinService.m

494 lines
24 KiB
Mathematica
Raw Normal View History

2025-11-18 20:53:47 +08:00
//
// KBSkinService.m
// keyBoard
//
#import "KBSkinService.h"
#import "KBSkinManager.h"
#import "KBConfig.h"
#import "KBKeyboardPermissionManager.h"
#import "KBNetworkManager.h"
#import "KBHUD.h"
2025-11-19 14:54:45 +08:00
#if __has_include(<SSZipArchive/SSZipArchive.h>)
#import <SSZipArchive/SSZipArchive.h>
#endif
2025-11-18 20:53:47 +08:00
@implementation KBSkinService
2025-11-19 14:54:45 +08:00
#pragma mark - Icon short-name mapping (local default)
/// ->
/// - skinJSON key_icons Zip 退使 zip_url +
+ (NSDictionary<NSString *, NSString *> *)kb_defaultIconShortNames {
static NSDictionary<NSString *, NSString *> *map;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
map = @{
//
2025-11-19 15:07:24 +08:00
@"letter_q_lower": @"key_q", // q
@"letter_q_upper": @"key_q", // Q
@"letter_w_lower": @"key_w", // w
@"letter_w_upper": @"key_w", // W
@"letter_e_lower": @"key_e", // e
@"letter_e_upper": @"key_e", // E
@"letter_r_lower": @"key_r", // r
@"letter_r_upper": @"key_r", // R
@"letter_t_lower": @"key_t", // t
@"letter_t_upper": @"key_t", // T
@"letter_y_lower": @"key_y", // y
@"letter_y_upper": @"key_y", // Y
@"letter_u_lower": @"key_u", // u
@"letter_u_upper": @"key_u", // U
@"letter_i_lower": @"key_i", // i
@"letter_i_upper": @"key_i", // I
@"letter_o_lower": @"key_o", // o
@"letter_o_upper": @"key_o", // O
@"letter_p_lower": @"key_p", // p
@"letter_p_upper": @"key_p", // P
@"letter_a_lower": @"key_a", // a
@"letter_a_upper": @"key_a", // A
@"letter_s_lower": @"key_s", // s
@"letter_s_upper": @"key_s", // S
@"letter_d_lower": @"key_d", // d
@"letter_d_upper": @"key_d", // D
@"letter_f_lower": @"key_f", // f
@"letter_f_upper": @"key_f", // F
@"letter_g_lower": @"key_g", // g
@"letter_g_upper": @"key_g", // G
@"letter_h_lower": @"key_h", // h
@"letter_h_upper": @"key_h", // H
@"letter_j_lower": @"key_j", // j
@"letter_j_upper": @"key_j", // J
@"letter_k_lower": @"key_k", // k
@"letter_k_upper": @"key_k", // K
@"letter_l_lower": @"key_l", // l
@"letter_l_upper": @"key_l", // L
@"letter_z_lower": @"key_z", // z
@"letter_z_upper": @"key_z", // Z
@"letter_x_lower": @"key_x", // x
@"letter_x_upper": @"key_x", // X
@"letter_c_lower": @"key_c", // c
@"letter_c_upper": @"key_c", // C
@"letter_v_lower": @"key_v", // v
@"letter_v_upper": @"key_v", // V
@"letter_b_lower": @"key_b", // b
@"letter_b_upper": @"key_b", // B
@"letter_n_lower": @"key_n", // n
@"letter_n_upper": @"key_n", // N
@"letter_m_lower": @"key_m", // m
@"letter_m_upper": @"key_m", // M
// 1~0
@"digit_1": @"key_1", // 1
@"digit_2": @"key_2", // 2
@"digit_3": @"key_3", // 3
@"digit_4": @"key_4", // 4
@"digit_5": @"key_5", // 5
@"digit_6": @"key_6", // 6
@"digit_7": @"key_7", // 7
@"digit_8": @"key_8", // 8
@"digit_9": @"key_9", // 9
@"digit_0": @"key_0", // 0
// 123 +
@"sym_minus": @"key_minus", // '-'
@"sym_slash": @"key_slash", // '/'
@"sym_colon": @"key_colon", // ':'
@"sym_semicolon": @"key_semicolon",// ';'
@"sym_paren_l": @"key_paren_l", // '('
@"sym_paren_r": @"key_paren_r", // ')'
@"sym_dollar": @"key_dollar", // '$'
@"sym_amp": @"key_amp", // '&'
@"sym_at": @"key_at", // '@'
@"sym_quote_double": @"key_quote_d", // "
@"sym_comma": @"key_comma", // ','
@"sym_dot": @"key_dot", // '.'
@"sym_question": @"key_question", // '?'
@"sym_exclam": @"key_exclam", // '!'
@"sym_quote_single": @"key_quote", // '
// #+= /
@"sym_bracket_l": @"key_bracket_l", // '['
@"sym_bracket_r": @"key_bracket_r", // ']'
@"sym_brace_l": @"key_brace_l", // '{'
@"sym_brace_r": @"key_brace_r", // '}'
@"sym_hash": @"key_hash", // '#'
@"sym_percent": @"key_percent", // '%'
@"sym_caret": @"key_caret", // '^'
@"sym_asterisk": @"key_asterisk", // '*'
@"sym_plus": @"key_plus", // '+'
@"sym_equal": @"key_equal", // '='
@"sym_underscore": @"key_underscore", // '_'
@"sym_backslash": @"key_backslash", // '\'
@"sym_pipe": @"key_pipe", // '|'
@"sym_tilde": @"key_tilde", // '~'
@"sym_lt": @"key_lt", // '<'
@"sym_gt": @"key_gt", // '>'
@"sym_euro": @"key_euro", // ''
@"sym_pound": @"key_pound", // '£'
@"sym_bullet": @"key_bullet", // ''
//
@"space": @"key_space", //
@"backspace": @"key_del", //
@"shift": @"key_up", // Shift
@"mode_123": @"key_123", // "123"
@"mode_abc": @"key_abc", // "abc"
@"symbols_toggle_more": @"key_symbols_more", // "123 -> #+="
@"symbols_toggle_123": @"key_symbols_123", // "#+= -> 123"
@"ai": @"key_ai", // AI
@"return": @"key_send" // /
2025-11-19 14:54:45 +08:00
};
});
return map;
}
2025-11-18 20:53:47 +08:00
+ (instancetype)shared {
static KBSkinService *s; static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{ s = [KBSkinService new]; });
return s;
}
- (void)applySkinWithJSON:(NSDictionary *)skinJSON
fromViewController:(UIViewController *)presenting
completion:(KBSkinApplyCompletion)completion {
if (skinJSON.count == 0) {
if (completion) completion(NO);
return;
}
// 1. & 访
KBKeyboardPermissionManager *perm = [KBKeyboardPermissionManager shared];
BOOL enabled = [perm isKeyboardEnabled];
KBFARecord fa = [perm lastKnownFullAccess];
BOOL hasFullAccess = (fa == KBFARecordGranted);
if (!enabled || !hasFullAccess) {
//
[perm presentPermissionIfNeededFrom:presenting];
// 访 App Group
[KBHUD showInfo:KBLocalized(@"皮肤已应用,键盘需开启“允许完全访问”后才能显示图片")];
}
#if KB_SKIN_ICON_USE_REMOTE
[self kb_applySkinUsingRemoteIcons:skinJSON completion:completion];
#else
[self kb_applySkinUsingLocalIcons:skinJSON completion:completion];
#endif
}
#pragma mark - Internal helpers
/// key_icons value Assets App bundle App Group
- (void)kb_applySkinUsingLocalIcons:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion {
NSString *skinId = skin[@"id"] ?: @"local";
NSString *name = skin[@"name"] ?: skinId;
NSDictionary *iconNames = [skin[@"key_icons"] isKindOfClass:NSDictionary.class] ? skin[@"key_icons"] : @{};
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (containerURL && iconNames.count > 0) {
NSString *skinsRoot = [containerURL.path stringByAppendingPathComponent:@"Skins"];
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
NSString *iconsDir = [skinRoot stringByAppendingPathComponent:@"icons"];
[[NSFileManager defaultManager] createDirectoryAtPath:iconsDir
withIntermediateDirectories:YES
attributes:nil
error:NULL];
[iconNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *imageName, BOOL *stop) {
if (![imageName isKindOfClass:NSString.class] || imageName.length == 0) return;
UIImage *img = [UIImage imageNamed:imageName];
if (!img) return;
NSData *data = UIImagePNGRepresentation(img);
if (data.length == 0) return;
NSString *fileName = [NSString stringWithFormat:@"%@.png", identifier];
NSString *fullPath = [iconsDir stringByAppendingPathComponent:fileName];
if ([data writeToFile:fullPath atomically:YES]) {
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
iconPathMap[identifier] = relative;
}
}];
}
NSMutableDictionary *themeJSON = [skin mutableCopy];
if (iconPathMap.count > 0) {
themeJSON[@"key_icons"] = iconPathMap.copy; // value App Group
}
// / hidden_keys / key_icons App Group
BOOL themeOK = [[KBSkinManager shared] applyThemeFromJSON:themeJSON];
// background_image
NSString *bgURL = skin[@"background_image"] ?: @"";
if (bgURL.length == 0) {
if (completion) completion(themeOK);
if (themeOK) {
[KBHUD showInfo:KBLocalized(@"已应用皮肤(无背景图)")];
} else {
[KBHUD showInfo:KBLocalized(@"应用皮肤失败")];
}
return;
}
[[KBNetworkManager shared] GET:bgURL parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
NSData *data = ([jsonOrData isKindOfClass:NSData.class] ? (NSData *)jsonOrData : nil);
// Keychain
if (data && data.length > 0) {
UIImage *img = [UIImage imageWithData:data];
if (img) {
CGFloat maxW = 1500.0;
if (img.size.width > maxW) {
CGFloat scale = maxW / img.size.width;
CGSize newSize = CGSizeMake(maxW, floor(img.size.height * scale));
UIGraphicsBeginImageContextWithOptions(newSize, YES, 1.0);
[img drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
UIImage *resized = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
img = resized ?: img;
}
data = UIImageJPEGRepresentation(img, 0.85) ?: data;
}
}
dispatch_async(dispatch_get_main_queue(), ^{
NSData *payload = data;
if (payload.length == 0) {
//
CGSize size = CGSizeMake(1200, 600);
UIGraphicsBeginImageContextWithOptions(size, YES, 1.0);
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIColor *c1 = [UIColor colorWithRed:0.76 green:0.91 blue:0.86 alpha:1];
UIColor *c2 = [UIColor colorWithRed:0.93 green:0.97 blue:0.91 alpha:1];
if ([skin[@"id"] hasPrefix:@"dark"]) {
c1 = [UIColor colorWithRed:0.1 green:0.12 blue:0.16 alpha:1];
c2 = [UIColor colorWithRed:0.22 green:0.24 blue:0.28 alpha:1];
}
CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB();
NSArray *colors = @[(__bridge id)c1.CGColor, (__bridge id)c2.CGColor];
CGFloat locs[] = {0,1};
CGGradientRef grad = CGGradientCreateWithColors(space, (__bridge CFArrayRef)colors, locs);
CGContextDrawLinearGradient(ctx, grad, CGPointZero, CGPointMake(size.width, size.height), 0);
CGGradientRelease(grad); CGColorSpaceRelease(space);
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
payload = UIImageJPEGRepresentation(img, 0.9);
}
BOOL ok = (payload.length > 0)
? [[KBSkinManager shared] applyImageSkinWithData:payload skinId:skinId name:name]
: themeOK;
if (completion) completion(ok);
[KBHUD showInfo:(ok ? KBLocalized(@"已应用,切到键盘查看") : KBLocalized(@"应用皮肤失败"))];
});
}];
}
2025-11-19 14:54:45 +08:00
/// Zip skinJSON zip_url
/// - Zip AppGroup/Skins/<skinId>/...
/// * icons/<shortName>.png icons/key_a.png
/// * background.png
/// - skinJSON.key_icons value Zip "key_a"
/// App Group Skins/<skinId>/icons/<shortName>.png
2025-11-18 20:53:47 +08:00
- (void)kb_applySkinUsingRemoteIcons:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion {
NSString *skinId = skin[@"id"] ?: @"remote";
NSString *name = skin[@"name"] ?: skinId;
2025-11-19 14:54:45 +08:00
NSString *zipURL = skin[@"zip_url"] ?: @""; // Zip
// key_icons
// - key_icons使
// - 退kb_defaultIconShortNames id/name/zip_url
NSDictionary *iconShortNames = nil;
if ([skin[@"key_icons"] isKindOfClass:NSDictionary.class]) {
iconShortNames = skin[@"key_icons"];
} else {
iconShortNames = [self.class kb_defaultIconShortNames];
}
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (!containerURL) {
if (completion) completion(NO);
[KBHUD showInfo:KBLocalized(@"无法访问共享容器,应用皮肤失败")];
return;
}
NSString *skinsRoot = [containerURL.path stringByAppendingPathComponent:@"Skins"];
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
NSString *iconsDir = [skinRoot stringByAppendingPathComponent:@"icons"];
[[NSFileManager defaultManager] createDirectoryAtPath:iconsDir
withIntermediateDirectories:YES
attributes:nil
error:NULL];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
BOOL hasIconsDir = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
NSArray *contents = hasIconsDir ? [fm contentsOfDirectoryAtPath:iconsDir error:NULL] : nil;
BOOL hasCachedAssets = (contents.count > 0);
NSString *bgPath = [skinRoot stringByAppendingPathComponent:@"background.png"];
2025-11-18 20:53:47 +08:00
dispatch_group_t group = dispatch_group_create();
2025-11-19 14:54:45 +08:00
__block BOOL zipOK = YES;
2025-11-18 20:53:47 +08:00
2025-11-19 14:54:45 +08:00
#if __has_include(<SSZipArchive/SSZipArchive.h>)
// zip_url Zip
2025-11-19 15:39:47 +08:00
//
// 1) 线 URLhttp/https KBNetworkManager
// 2) zip_url "bundle://" "bundle://001.zip"
2025-11-19 14:54:45 +08:00
if (!hasCachedAssets && zipURL.length > 0) {
2025-11-18 20:53:47 +08:00
dispatch_group_enter(group);
2025-11-19 15:39:47 +08:00
void (^handleZipData)(NSData *) = ^(NSData *data) {
if (data.length == 0) {
2025-11-19 14:54:45 +08:00
zipOK = NO;
dispatch_group_leave(group);
return;
}
// Zip
[[NSFileManager defaultManager] createDirectoryAtPath:skinRoot
withIntermediateDirectories:YES
attributes:nil
error:NULL];
NSString *zipPath = [skinRoot stringByAppendingPathComponent:@"skin.zip"];
if (![data writeToFile:zipPath atomically:YES]) {
zipOK = NO;
dispatch_group_leave(group);
return;
}
NSError *unzipError = nil;
BOOL ok = [SSZipArchive unzipFileAtPath:zipPath
toDestination:skinRoot
overwrite:YES
password:nil
error:&unzipError];
[fm removeItemAtPath:zipPath error:nil];
if (!ok || unzipError) {
zipOK = NO;
2025-11-19 15:39:47 +08:00
dispatch_group_leave(group);
return;
}
//
// Skins/<skinId>/icons Skins/<skinId>/<>/icons
// icons background.png
BOOL isDir2 = NO;
NSArray *iconsContent = [fm contentsOfDirectoryAtPath:iconsDir error:NULL];
BOOL iconsValid = ([fm fileExistsAtPath:iconsDir isDirectory:&isDir2] && isDir2 && iconsContent.count > 0);
if (!iconsValid) {
NSArray<NSString *> *subItems = [fm contentsOfDirectoryAtPath:skinRoot error:NULL];
for (NSString *name in subItems) {
if ([name isEqualToString:@"icons"] || [name isEqualToString:@"__MACOSX"]) continue;
NSString *nestedRoot = [skinRoot stringByAppendingPathComponent:name];
BOOL isDirNested = NO;
if (![fm fileExistsAtPath:nestedRoot isDirectory:&isDirNested] || !isDirNested) continue;
NSString *nestedIcons = [nestedRoot stringByAppendingPathComponent:@"icons"];
BOOL isDirNestedIcons = NO;
if ([fm fileExistsAtPath:nestedIcons isDirectory:&isDirNestedIcons] && isDirNestedIcons) {
NSArray *nestedFiles = [fm contentsOfDirectoryAtPath:nestedIcons error:NULL];
if (nestedFiles.count > 0) {
// icons
[fm createDirectoryAtPath:iconsDir
withIntermediateDirectories:YES
attributes:nil
error:NULL];
// icons
for (NSString *fn in nestedFiles) {
NSString *from = [nestedIcons stringByAppendingPathComponent:fn];
NSString *to = [iconsDir stringByAppendingPathComponent:fn];
[fm removeItemAtPath:to error:nil];
[fm moveItemAtPath:from toPath:to error:nil];
}
}
}
// background.png skinRoot
NSString *nestedBg = [nestedRoot stringByAppendingPathComponent:@"background.png"];
if ([fm fileExistsAtPath:nestedBg]) {
[fm removeItemAtPath:bgPath error:nil];
[fm moveItemAtPath:nestedBg toPath:bgPath error:nil];
}
}
2025-11-18 20:53:47 +08:00
}
dispatch_group_leave(group);
2025-11-19 15:39:47 +08:00
};
// bundle zip_url "bundle://001.zip"
if ([zipURL hasPrefix:@"bundle://"]) {
NSString *name = [zipURL substringFromIndex:@"bundle://".length];
NSString *fileName = name ?: @"";
NSString *ext = fileName.pathExtension;
NSString *base = fileName;
if (ext.length == 0) {
ext = @"zip";
} else {
base = [fileName stringByDeletingPathExtension];
}
NSString *path = [[NSBundle mainBundle] pathForResource:base ofType:ext];
NSData *data = (path.length > 0) ? [NSData dataWithContentsOfFile:path] : nil;
handleZipData(data);
} else {
//
[[KBNetworkManager shared] GET:zipURL parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
NSData *data = ([jsonOrData isKindOfClass:NSData.class] ? (NSData *)jsonOrData : nil);
if (error || data.length == 0) {
zipOK = NO;
dispatch_group_leave(group);
return;
}
handleZipData(data);
}];
}
2025-11-18 20:53:47 +08:00
}
2025-11-19 14:54:45 +08:00
#else
zipOK = NO;
#endif
2025-11-18 20:53:47 +08:00
2025-11-19 14:54:45 +08:00
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// key_icons -> App Group
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
[iconShortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
if (![shortName isKindOfClass:NSString.class] || shortName.length == 0) return;
NSString *fileName = shortName;
// .png
if (fileName.pathExtension.length == 0) {
fileName = [fileName stringByAppendingPathExtension:@"png"];
2025-11-18 20:53:47 +08:00
}
2025-11-19 14:54:45 +08:00
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
iconPathMap[identifier] = relative;
2025-11-18 20:53:47 +08:00
}];
NSMutableDictionary *themeJSON = [skin mutableCopy];
2025-11-19 14:54:45 +08:00
themeJSON[@"id"] = skinId;
if (iconPathMap.count > 0) {
themeJSON[@"key_icons"] = iconPathMap.copy;
}
2025-11-18 20:53:47 +08:00
BOOL themeOK = [[KBSkinManager shared] applyThemeFromJSON:themeJSON];
2025-11-19 14:54:45 +08:00
// Zip background.png
NSData *bgData = [NSData dataWithContentsOfFile:bgPath];
2025-11-18 20:53:47 +08:00
BOOL ok = themeOK;
if (bgData.length > 0) {
ok = [[KBSkinManager shared] applyImageSkinWithData:bgData skinId:skinId name:name];
}
2025-11-19 14:54:45 +08:00
if (!zipOK && !hasCachedAssets) {
ok = NO;
}
2025-11-18 20:53:47 +08:00
if (completion) completion(ok);
[KBHUD showInfo:(ok ? KBLocalized(@"已应用,切到键盘查看") : KBLocalized(@"应用皮肤失败"))];
});
}
@end