// // KBSkinService.m // keyBoard // #import "KBSkinService.h" #import "KBSkinManager.h" #import "KBConfig.h" #import "KBKeyboardPermissionManager.h" #import "KBNetworkManager.h" #import "KBHUD.h" #if __has_include() #import #endif @implementation KBSkinService #pragma mark - Icon short-name mapping (local default) /// 本地维护的一份“逻辑按键标识 -> 图标短文件名”映射表。 /// - 若后端 skinJSON 未提供 key_icons,则远程 Zip 模式会回退使用本表;仅依赖 zip_url + 命名规范即可。 + (NSDictionary *)kb_defaultIconShortNames { static NSDictionary *map; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ map = @{ // 字母键(大小写共用) @"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" // 发送/换行键 }; }); return map; } + (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 *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(@"应用皮肤失败"))]; }); }]; } /// 远程 Zip 模式:skinJSON 提供 zip_url,一套皮肤一个压缩包。 /// - Zip 解压路径:AppGroup/Skins//... /// * 图标:icons/.png,例如 icons/key_a.png /// * 背景:background.png(可选) /// - skinJSON.key_icons 的 value 填写 Zip 内的图标“短文件名”(不含路径,可不含扩展名),例如 "key_a" /// 应用时会被转换为相对 App Group 根目录的路径:Skins//icons/.png - (void)kb_applySkinUsingRemoteIcons:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion { NSString *skinId = skin[@"id"] ?: @"remote"; NSString *name = skin[@"name"] ?: skinId; 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"]; dispatch_group_t group = dispatch_group_create(); __block BOOL zipOK = YES; #if __has_include() // 若本地尚未缓存该皮肤资源且提供了 zip_url,则下载并解压 Zip 包。 if (!hasCachedAssets && zipURL.length > 0) { dispatch_group_enter(group); [[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; } // 将 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; } dispatch_group_leave(group); }]; } #else zipOK = NO; #endif dispatch_group_notify(group, dispatch_get_main_queue(), ^{ // 构造 key_icons -> App Group 相对路径 映射 NSMutableDictionary *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"]; } NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName]; iconPathMap[identifier] = relative; }]; NSMutableDictionary *themeJSON = [skin mutableCopy]; themeJSON[@"id"] = skinId; if (iconPathMap.count > 0) { themeJSON[@"key_icons"] = iconPathMap.copy; } BOOL themeOK = [[KBSkinManager shared] applyThemeFromJSON:themeJSON]; // 背景图优先从 Zip 解压出的 background.png 读取 NSData *bgData = [NSData dataWithContentsOfFile:bgPath]; BOOL ok = themeOK; if (bgData.length > 0) { ok = [[KBSkinManager shared] applyImageSkinWithData:bgData skinId:skinId name:name]; } if (!zipOK && !hasCachedAssets) { ok = NO; } if (completion) completion(ok); [KBHUD showInfo:(ok ? KBLocalized(@"已应用,切到键盘查看") : KBLocalized(@"应用皮肤失败"))]; }); } @end