// // 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, ^{ // 从配置文件加载映射,避免在代码里维护大段字面量。 // 使用 .strings 形式,便于为每个键添加注释。 NSString *path = [[NSBundle mainBundle] pathForResource:@"KBSkinIconMap" ofType:@"strings"]; if (path.length > 0) { NSDictionary *dict = [NSDictionary dictionaryWithContentsOfFile:path]; if ([dict isKindOfClass:NSDictionary.class]) { map = dict; } } if (!map) { map = @{}; // 防御:配置缺失时返回空表,避免崩溃 } }); 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 mode:(KBSkinSourceMode)mode 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(@"皮肤已应用,键盘需开启“允许完全访问”后才能显示图片")]; // } switch (mode) { case KBSkinSourceModeLocalBundleZip: // 本地 bundle 模式:zip_url 为 bundle 内的 zip 文件名 [self kb_applySkinUsingLocalBundle:skinJSON completion:completion]; break; case KBSkinSourceModeRemoteZip: default: // 远程模式:zip_url 为 http/https 地址 [self kb_applySkinUsingRemoteIcons:skinJSON completion:completion]; break; } } /// 远程 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); void (^handleZipData)(NSData *) = ^(NSData *data) { if (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); return; } // 兼容“额外包一层目录”的压缩结构: // 若 Skins//icons 为空,但存在 Skins//<子目录>/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 *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]; } } } dispatch_group_leave(group); }; // 远程下载(http/https) [[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); }]; } #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(@"应用皮肤失败"))]; }); } /// 本地 bundle 模式:不走网络,skin[@"zip_url"] 直接为 bundle 内 zip 文件名(可带/不带扩展名)。 /// - 仍然解压到 AppGroup/Skins//...,方便键盘扩展通过 App Group 读取。 - (void)kb_applySkinUsingLocalBundle:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion { NSString *skinId = skin[@"id"] ?: @"local"; NSString *name = skin[@"name"] ?: skinId; NSString *zipName = skin[@"zip_url"] ?: @""; // 本地 bundle 内的 zip 文件名 // key_icons 逻辑与远程模式保持一致 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() // 若本地尚未缓存该皮肤资源且提供了 zipName,则从 bundle 读取 zip 并解压。 if (!hasCachedAssets && zipName.length > 0) { dispatch_group_enter(group); // 兼容旧协议:zipName 可能形如 "bundle://001.zip" NSString *fileName = zipName ?: @""; if ([fileName hasPrefix:@"bundle://"]) { fileName = [fileName substringFromIndex:@"bundle://".length]; } // 支持带子路径,例如 Images/001.zip NSString *dir = [fileName stringByDeletingLastPathComponent]; NSString *last = fileName.lastPathComponent; NSString *ext = last.pathExtension; NSString *base = last; if (ext.length == 0) { ext = @"zip"; } else { base = [last stringByDeletingPathExtension]; } NSString *path = nil; if (dir.length > 0) { path = [[NSBundle mainBundle] pathForResource:base ofType:ext inDirectory:dir]; } else { path = [[NSBundle mainBundle] pathForResource:base ofType:ext]; } NSData *data = (path.length > 0) ? [NSData dataWithContentsOfFile:path] : nil; if (data.length == 0) { zipOK = NO; dispatch_group_leave(group); } else { // 将 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); } else { 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 { // 兼容“额外包一层目录”的压缩结构,与远程模式保持一致。 BOOL isDir2 = NO; NSArray *iconsContent = [fm contentsOfDirectoryAtPath:iconsDir error:NULL]; BOOL iconsValid = ([fm fileExistsAtPath:iconsDir isDirectory:&isDir2] && isDir2 && iconsContent.count > 0); if (!iconsValid) { NSArray *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) { [fm createDirectoryAtPath:iconsDir withIntermediateDirectories:YES attributes:nil error:NULL]; 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]; } } } NSString *nestedBg = [nestedRoot stringByAppendingPathComponent:@"background.png"]; if ([fm fileExistsAtPath:nestedBg]) { [fm removeItemAtPath:bgPath error:nil]; [fm moveItemAtPath:nestedBg toPath:bgPath error:nil]; } } } 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; 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]; 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