Files
keyboard/keyBoard/Class/Manager/KBSkinService.m

456 lines
21 KiB
Mathematica
Raw Normal View History

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