Files
keyboard/keyBoard/Class/Manager/KBSkinService.m
2025-11-19 20:30:30 +08:00

456 lines
21 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// KBSkinService.m
// keyBoard
//
#import "KBSkinService.h"
#import "KBSkinManager.h"
#import "KBConfig.h"
#import "KBKeyboardPermissionManager.h"
#import "KBNetworkManager.h"
#import "KBHUD.h"
#if __has_include(<SSZipArchive/SSZipArchive.h>)
#import <SSZipArchive/SSZipArchive.h>
#endif
@implementation KBSkinService
#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, ^{
// 从配置文件加载映射,避免在代码里维护大段字面量。
// 使用 .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 {
// 模式为“恢复默认皮肤”时,直接调用 KBSkinManager 的 reset 接口,忽略 JSON 内容。
if (mode == KBSkinSourceModeResetToDefault) {
[[KBSkinManager shared] resetToDefault];
if (completion) completion(YES);
[KBHUD showInfo:KBLocalized(@"已恢复默认键盘皮肤")];
return;
}
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/<skinId>/...
/// * 图标icons/<shortName>.png例如 icons/key_a.png
/// * 背景background.png可选
/// - skinJSON.key_icons 的 value 填写 Zip 内的图标“短文件名”(不含路径,可不含扩展名),例如 "key_a"
/// 应用时会被转换为相对 App Group 根目录的路径Skins/<skinId>/icons/<shortName>.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(<SSZipArchive/SSZipArchive.h>)
// 若本地尚未缓存该皮肤资源且提供了 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/<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];
}
}
}
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<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;
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<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);
}
}
}
}
#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;
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