Files
keyboard/keyBoard/Class/Manager/KBSkinService.m
2025-11-25 18:54:53 +08:00

282 lines
13 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"
#import "KBSkinInstallBridge.h"
#if __has_include(<SSZipArchive/SSZipArchive.h>)
#import <SSZipArchive/SSZipArchive.h>
#endif
@implementation KBSkinService
#pragma mark - Icon short-name mapping (local default)
+ (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 {
[KBSkinInstallBridge installRemoteSkinWithJSON:skin
completion:^(BOOL success, NSError * _Nullable error) {
if (completion) {
completion(success);
}
if (!success && error) {
NSLog(@"[KBSkinService] remote skin install failed: %@", error);
}
NSString *message = nil;
if (success) {
message = KBLocalized(@"已应用,切到键盘查看");
} else if ([error.domain isEqualToString:KBSkinBridgeErrorDomain] &&
error.code == KBSkinBridgeErrorContainerUnavailable) {
message = KBLocalized(@"无法访问共享容器,应用皮肤失败");
} else {
message = KBLocalized(@"应用皮肤失败");
}
[KBHUD showInfo:message];
}];
}
/// 本地 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 = [KBSkinInstallBridge 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