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, ^{
|
2025-11-19 20:16:19 +08:00
|
|
|
|
// 从配置文件加载映射,避免在代码里维护大段字面量。
|
|
|
|
|
|
// 使用 .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 = @{}; // 防御:配置缺失时返回空表,避免崩溃
|
|
|
|
|
|
}
|
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
|
2025-11-19 20:16:19 +08:00
|
|
|
|
mode:(KBSkinSourceMode)mode
|
2025-11-18 20:53:47 +08:00
|
|
|
|
completion:(KBSkinApplyCompletion)completion {
|
2025-11-19 20:30:30 +08:00
|
|
|
|
// 模式为“恢复默认皮肤”时,直接调用 KBSkinManager 的 reset 接口,忽略 JSON 内容。
|
|
|
|
|
|
if (mode == KBSkinSourceModeResetToDefault) {
|
|
|
|
|
|
[[KBSkinManager shared] resetToDefault];
|
|
|
|
|
|
if (completion) completion(YES);
|
|
|
|
|
|
[KBHUD showInfo:KBLocalized(@"已恢复默认键盘皮肤")];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-18 20:53:47 +08:00
|
|
|
|
if (skinJSON.count == 0) {
|
|
|
|
|
|
if (completion) completion(NO);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-19 19:15:28 +08:00
|
|
|
|
// // 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(@"皮肤已应用,键盘需开启“允许完全访问”后才能显示图片")];
|
|
|
|
|
|
// }
|
2025-11-18 20:53:47 +08:00
|
|
|
|
|
2025-11-19 20:16:19 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-11-18 20:53:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
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>)
|
2025-11-19 20:16:19 +08:00
|
|
|
|
// 若本地尚未缓存该皮肤资源且提供了 zip_url,则通过网络下载并解压 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
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-19 20:16:19 +08:00
|
|
|
|
// 远程下载(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<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"];
|
|
|
|
|
|
}
|
|
|
|
|
|
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/<skinId>/...,方便键盘扩展通过 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(<SSZipArchive/SSZipArchive.h>)
|
|
|
|
|
|
// 若本地尚未缓存该皮肤资源且提供了 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;
|
2025-11-19 15:39:47 +08:00
|
|
|
|
if (ext.length == 0) {
|
|
|
|
|
|
ext = @"zip";
|
|
|
|
|
|
} else {
|
2025-11-19 20:16:19 +08:00
|
|
|
|
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];
|
2025-11-19 15:39:47 +08:00
|
|
|
|
}
|
2025-11-19 20:16:19 +08:00
|
|
|
|
|
2025-11-19 15:39:47 +08:00
|
|
|
|
NSData *data = (path.length > 0) ? [NSData dataWithContentsOfFile:path] : nil;
|
2025-11-19 20:16:19 +08:00
|
|
|
|
|
|
|
|
|
|
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]) {
|
2025-11-19 15:39:47 +08:00
|
|
|
|
zipOK = NO;
|
|
|
|
|
|
dispatch_group_leave(group);
|
2025-11-19 20:16:19 +08:00
|
|
|
|
} 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<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) {
|
|
|
|
|
|
[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);
|
|
|
|
|
|
}
|
2025-11-19 15:39:47 +08:00
|
|
|
|
}
|
2025-11-19 20:16:19 +08:00
|
|
|
|
}
|
2025-11-19 15:39:47 +08:00
|
|
|
|
}
|
2025-11-19 20:16:19 +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;
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|