2025-11-20 14:27:57 +08:00
|
|
|
|
//
|
|
|
|
|
|
// KBSkinInstallBridge.m
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "KBSkinInstallBridge.h"
|
|
|
|
|
|
|
|
|
|
|
|
#import "KBConfig.h"
|
|
|
|
|
|
#import "KBSkinManager.h"
|
2025-11-25 18:54:53 +08:00
|
|
|
|
#if __has_include("KBNetworkManager.h")
|
|
|
|
|
|
#import "KBNetworkManager.h"
|
|
|
|
|
|
#endif
|
2025-11-20 14:27:57 +08:00
|
|
|
|
#if __has_include(<SSZipArchive/SSZipArchive.h>)
|
|
|
|
|
|
#import <SSZipArchive/SSZipArchive.h>
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
NSString * const KBDarwinSkinInstallRequestNotification = @"com.loveKey.nyx.skin.install.request";
|
2025-11-25 18:54:53 +08:00
|
|
|
|
NSErrorDomain const KBSkinBridgeErrorDomain = @"com.loveKey.nyx.skin.bridge";
|
2025-11-20 14:27:57 +08:00
|
|
|
|
|
|
|
|
|
|
static NSString * const kKBSkinPendingRequestKey = @"com.loveKey.nyx.skin.pending";
|
|
|
|
|
|
static NSString * const kKBSkinPendingSkinIdKey = @"skinId";
|
|
|
|
|
|
static NSString * const kKBSkinPendingSkinNameKey = @"name";
|
|
|
|
|
|
static NSString * const kKBSkinPendingZipKey = @"zipName";
|
|
|
|
|
|
static NSString * const kKBSkinPendingKindKey = @"kind";
|
|
|
|
|
|
static NSString * const kKBSkinPendingTimestampKey = @"timestamp";
|
|
|
|
|
|
static NSString * const kKBSkinPendingIconShortKey = @"iconShortNames";
|
|
|
|
|
|
|
|
|
|
|
|
@implementation KBSkinInstallBridge
|
|
|
|
|
|
|
|
|
|
|
|
+ (NSDictionary<NSString *,NSString *> *)defaultIconShortNames {
|
|
|
|
|
|
static NSDictionary<NSString *, NSString *> *map;
|
|
|
|
|
|
static dispatch_once_t onceToken;
|
|
|
|
|
|
dispatch_once(&onceToken, ^{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (NSUserDefaults *)sharedDefaults {
|
|
|
|
|
|
return [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 18:54:53 +08:00
|
|
|
|
+ (void)installRemoteSkinWithJSON:(NSDictionary *)skinJSON
|
|
|
|
|
|
completion:(KBSkinInstallConsumeCompletion)completion {
|
|
|
|
|
|
if (![skinJSON isKindOfClass:NSDictionary.class] || skinJSON.count == 0) {
|
|
|
|
|
|
if (completion) {
|
|
|
|
|
|
NSError *err = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorInvalidPayload
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, err); });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSString *skinId = skinJSON[@"id"] ?: @"remote";
|
|
|
|
|
|
NSString *name = skinJSON[@"name"] ?: skinId;
|
|
|
|
|
|
NSString *zipURL = skinJSON[@"zip_url"] ?: @"";
|
|
|
|
|
|
|
|
|
|
|
|
// key_icons 可选:
|
|
|
|
|
|
// - 若后端提供 key_icons,则优先使用服务端映射;
|
|
|
|
|
|
// - 若未提供,则回退到本地默认映射,这样后端只需返回 id/name/zip_url。
|
|
|
|
|
|
NSDictionary *iconShortNames = nil;
|
|
|
|
|
|
if ([skinJSON[@"key_icons"] isKindOfClass:NSDictionary.class]) {
|
|
|
|
|
|
iconShortNames = skinJSON[@"key_icons"];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
iconShortNames = [self defaultIconShortNames];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSFileManager *fm = [NSFileManager defaultManager];
|
|
|
|
|
|
NSURL *containerURL = [fm containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
|
|
|
|
|
if (!containerURL) {
|
|
|
|
|
|
if (completion) {
|
|
|
|
|
|
NSError *err = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorContainerUnavailable
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Shared container unavailable"}];
|
|
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{ completion(NO, err); });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSString *skinsRoot = [containerURL.path stringByAppendingPathComponent:@"Skins"];
|
|
|
|
|
|
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
|
|
|
|
|
|
NSString *iconsDir = [skinRoot stringByAppendingPathComponent:@"icons"];
|
|
|
|
|
|
[fm createDirectoryAtPath:iconsDir
|
|
|
|
|
|
withIntermediateDirectories:YES
|
|
|
|
|
|
attributes:nil
|
|
|
|
|
|
error:NULL];
|
|
|
|
|
|
|
|
|
|
|
|
BOOL isDir = NO;
|
|
|
|
|
|
BOOL hasIconsDir = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
|
|
|
|
|
|
NSArray *contents = hasIconsDir ? [fm contentsOfDirectoryAtPath:iconsDir error:NULL] : nil;
|
2025-11-25 22:00:13 +08:00
|
|
|
|
// 标记在本次请求发起前是否已经有缓存资源(用于“有缓存但本次下载失败”时仍允许切换皮肤)。
|
2025-11-25 18:54:53 +08:00
|
|
|
|
BOOL hasCachedAssets = (contents.count > 0);
|
|
|
|
|
|
|
|
|
|
|
|
NSString *bgPath = [skinRoot stringByAppendingPathComponent:@"background.png"];
|
|
|
|
|
|
|
|
|
|
|
|
dispatch_group_t group = dispatch_group_create();
|
|
|
|
|
|
__block BOOL zipOK = YES;
|
2025-11-25 22:00:13 +08:00
|
|
|
|
__block BOOL didUnzip = NO; // 标记本次流程中是否成功解压过 Zip
|
2025-11-25 18:54:53 +08:00
|
|
|
|
__block NSError *innerError = nil;
|
|
|
|
|
|
|
|
|
|
|
|
#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;
|
|
|
|
|
|
if (!innerError) {
|
|
|
|
|
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorZipMissing
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Zip data is empty"}];
|
|
|
|
|
|
}
|
|
|
|
|
|
dispatch_group_leave(group);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 将 Zip 写入临时路径再解压
|
|
|
|
|
|
[fm createDirectoryAtPath:skinRoot
|
|
|
|
|
|
withIntermediateDirectories:YES
|
|
|
|
|
|
attributes:nil
|
|
|
|
|
|
error:NULL];
|
|
|
|
|
|
NSString *zipPath = [skinRoot stringByAppendingPathComponent:@"skin.zip"];
|
|
|
|
|
|
if (![data writeToFile:zipPath atomically:YES]) {
|
|
|
|
|
|
zipOK = NO;
|
|
|
|
|
|
if (!innerError) {
|
|
|
|
|
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorUnzipFailed
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to write zip file"}];
|
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
if (!innerError) {
|
|
|
|
|
|
innerError = unzipError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorUnzipFailed
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
dispatch_group_leave(group);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 22:00:13 +08:00
|
|
|
|
// 标记已成功解压一次(即使 icons 目录结构需要后续整理)。
|
|
|
|
|
|
didUnzip = YES;
|
|
|
|
|
|
|
2025-11-25 18:54:53 +08:00
|
|
|
|
// 兼容“额外包一层目录”的压缩结构:
|
|
|
|
|
|
// 若 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 *subName in subItems) {
|
|
|
|
|
|
if ([subName isEqualToString:@"icons"] || [subName isEqualToString:@"__MACOSX"]) continue;
|
|
|
|
|
|
NSString *nestedRoot = [skinRoot stringByAppendingPathComponent:subName];
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
#if __has_include("KBNetworkManager.h")
|
|
|
|
|
|
// 远程下载(http/https)
|
2025-11-25 21:50:07 +08:00
|
|
|
|
NSLog(@"[SkinBridge] will GET zip: %@", zipURL);
|
2025-11-25 20:35:08 +08:00
|
|
|
|
[KBHUD show];
|
2025-11-25 18:54:53 +08:00
|
|
|
|
[[KBNetworkManager shared] GET:zipURL parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
|
2025-11-25 21:50:07 +08:00
|
|
|
|
NSLog(@"[SkinBridge] GET finished, error = %@", error);
|
2025-11-25 22:00:13 +08:00
|
|
|
|
// [KBHUD dismiss];
|
2025-11-25 18:54:53 +08:00
|
|
|
|
NSData *data = ([jsonOrData isKindOfClass:NSData.class] ? (NSData *)jsonOrData : nil);
|
|
|
|
|
|
if (error || data.length == 0) {
|
|
|
|
|
|
zipOK = NO;
|
|
|
|
|
|
if (!innerError) {
|
|
|
|
|
|
innerError = error ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorZipMissing
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to download zip"}];
|
|
|
|
|
|
}
|
|
|
|
|
|
dispatch_group_leave(group);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
handleZipData(data);
|
|
|
|
|
|
}];
|
|
|
|
|
|
#else
|
|
|
|
|
|
// 无 KBNetworkManager 时,退回到简单的 dataWithContentsOfURL 下载(阻塞当前线程)
|
|
|
|
|
|
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
|
|
|
|
|
NSURL *url = [NSURL URLWithString:zipURL];
|
|
|
|
|
|
NSData *data = url ? [NSData dataWithContentsOfURL:url] : nil;
|
|
|
|
|
|
if (!data) {
|
|
|
|
|
|
zipOK = NO;
|
|
|
|
|
|
if (!innerError) {
|
|
|
|
|
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorZipMissing
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Failed to download zip"}];
|
|
|
|
|
|
}
|
|
|
|
|
|
dispatch_group_leave(group);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
handleZipData(data);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
#else
|
|
|
|
|
|
zipOK = NO;
|
|
|
|
|
|
innerError = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorUnzipFailed
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"SSZipArchive not available"}];
|
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
|
|
// 解压与下载完成后,构造主题并应用
|
|
|
|
|
|
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
|
2025-11-25 22:00:13 +08:00
|
|
|
|
// 若既没有预先存在的缓存资源,也没有在本次流程中成功解压出资源,
|
|
|
|
|
|
// 说明当前皮肤 B 的资源完全不可用,此时不应覆盖现有皮肤主题。
|
|
|
|
|
|
BOOL hasAssets = (hasCachedAssets || didUnzip);
|
|
|
|
|
|
if (!hasAssets) {
|
|
|
|
|
|
NSError *finalError = innerError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorZipMissing
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Zip resource not available"}];
|
|
|
|
|
|
if (completion) completion(NO, finalError);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 18:54:53 +08:00
|
|
|
|
// 构造 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 = [skinJSON 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSError *finalError = nil;
|
|
|
|
|
|
if (!ok) {
|
|
|
|
|
|
finalError = innerError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorApplyFailed
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (completion) completion(ok, finalError);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 14:27:57 +08:00
|
|
|
|
+ (void)publishBundleSkinRequestWithId:(NSString *)skinId
|
|
|
|
|
|
name:(NSString *)name
|
|
|
|
|
|
zipName:(NSString *)zipName
|
|
|
|
|
|
iconShortNames:(NSDictionary<NSString *,NSString *> *)iconShortNames {
|
|
|
|
|
|
if (skinId.length == 0 || zipName.length == 0) { return; }
|
|
|
|
|
|
NSMutableDictionary *payload = [NSMutableDictionary dictionary];
|
|
|
|
|
|
payload[kKBSkinPendingSkinIdKey] = skinId;
|
|
|
|
|
|
payload[kKBSkinPendingSkinNameKey] = name.length > 0 ? name : skinId;
|
|
|
|
|
|
payload[kKBSkinPendingZipKey] = zipName;
|
|
|
|
|
|
payload[kKBSkinPendingKindKey] = @"bundle";
|
|
|
|
|
|
payload[kKBSkinPendingTimestampKey] = @([[NSDate date] timeIntervalSince1970]);
|
|
|
|
|
|
if (iconShortNames.count > 0) {
|
|
|
|
|
|
payload[kKBSkinPendingIconShortKey] = iconShortNames;
|
|
|
|
|
|
}
|
|
|
|
|
|
[[self sharedDefaults] setObject:payload forKey:kKBSkinPendingRequestKey];
|
|
|
|
|
|
[[self sharedDefaults] synchronize];
|
|
|
|
|
|
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
|
|
|
|
|
|
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
|
|
|
|
|
NULL,
|
|
|
|
|
|
NULL,
|
|
|
|
|
|
true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (NSDictionary *)pendingRequestPayload {
|
|
|
|
|
|
id payload = [[self sharedDefaults] objectForKey:kKBSkinPendingRequestKey];
|
|
|
|
|
|
if ([payload isKindOfClass:NSDictionary.class]) {
|
|
|
|
|
|
return payload;
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (void)clearPendingRequest {
|
|
|
|
|
|
[[self sharedDefaults] removeObjectForKey:kKBSkinPendingRequestKey];
|
|
|
|
|
|
[[self sharedDefaults] synchronize];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (void)consumePendingRequestFromBundle:(NSBundle *)bundle
|
|
|
|
|
|
completion:(KBSkinInstallConsumeCompletion)completion {
|
|
|
|
|
|
NSDictionary *payload = [self pendingRequestPayload];
|
|
|
|
|
|
if (payload.count == 0) {
|
|
|
|
|
|
if (completion) completion(NO, nil);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
|
|
|
|
|
|
NSError *error = nil;
|
|
|
|
|
|
BOOL ok = [self processPayload:payload bundle:bundle ?: [NSBundle mainBundle] error:&error];
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
[self clearPendingRequest];
|
|
|
|
|
|
}
|
|
|
|
|
|
dispatch_async(dispatch_get_main_queue(), ^{
|
|
|
|
|
|
if (completion) completion(ok, error);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
+ (BOOL)processPayload:(NSDictionary *)payload
|
|
|
|
|
|
bundle:(NSBundle *)bundle
|
|
|
|
|
|
error:(NSError * __autoreleasing *)error {
|
|
|
|
|
|
#if !__has_include(<SSZipArchive/SSZipArchive.h>)
|
|
|
|
|
|
if (error) {
|
2025-11-25 18:54:53 +08:00
|
|
|
|
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
2025-11-20 14:27:57 +08:00
|
|
|
|
code:KBSkinBridgeErrorUnzipFailed
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"SSZipArchive not available"}];
|
|
|
|
|
|
}
|
|
|
|
|
|
return NO;
|
|
|
|
|
|
#else
|
|
|
|
|
|
NSString *skinId = payload[kKBSkinPendingSkinIdKey];
|
|
|
|
|
|
NSString *name = payload[kKBSkinPendingSkinNameKey] ?: skinId;
|
|
|
|
|
|
NSString *zipName = payload[kKBSkinPendingZipKey];
|
|
|
|
|
|
if (skinId.length == 0 || zipName.length == 0) {
|
|
|
|
|
|
if (error) {
|
2025-11-25 18:54:53 +08:00
|
|
|
|
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
2025-11-20 14:27:57 +08:00
|
|
|
|
code:KBSkinBridgeErrorInvalidPayload
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
return NO;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 18:23:56 +08:00
|
|
|
|
// 皮肤根目录:优先 App Group,若不可写则退回当前进程的 Caches 目录。
|
|
|
|
|
|
NSFileManager *fm = [NSFileManager defaultManager];
|
|
|
|
|
|
NSString *baseRoot = nil;
|
|
|
|
|
|
NSURL *containerURL = [fm containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
|
|
|
|
|
if (containerURL.path.length > 0) {
|
|
|
|
|
|
// 探测写权限:在 Skins/.kb_write_test 下创建临时目录
|
|
|
|
|
|
NSString *testDir = [[containerURL.path stringByAppendingPathComponent:@"Skins"]
|
|
|
|
|
|
stringByAppendingPathComponent:@".kb_write_test"];
|
|
|
|
|
|
NSError *probeError = nil;
|
|
|
|
|
|
BOOL canWrite = [fm createDirectoryAtPath:testDir
|
|
|
|
|
|
withIntermediateDirectories:YES
|
|
|
|
|
|
attributes:nil
|
|
|
|
|
|
error:&probeError];
|
|
|
|
|
|
if (canWrite) {
|
|
|
|
|
|
baseRoot = containerURL.path;
|
|
|
|
|
|
[fm removeItemAtPath:testDir error:NULL];
|
2025-11-20 14:27:57 +08:00
|
|
|
|
}
|
2025-11-20 18:23:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (baseRoot.length == 0) {
|
|
|
|
|
|
NSArray<NSString *> *dirs = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
|
|
|
|
|
|
baseRoot = dirs.firstObject ?: NSTemporaryDirectory();
|
2025-11-20 14:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 18:23:56 +08:00
|
|
|
|
NSString *skinsRoot = [baseRoot stringByAppendingPathComponent:@"Skins"];
|
2025-11-20 14:27:57 +08:00
|
|
|
|
NSString *skinRoot = [skinsRoot stringByAppendingPathComponent:skinId];
|
|
|
|
|
|
NSString *iconsDir = [skinRoot stringByAppendingPathComponent:@"icons"];
|
2025-11-20 18:23:56 +08:00
|
|
|
|
[fm createDirectoryAtPath:iconsDir
|
|
|
|
|
|
withIntermediateDirectories:YES
|
|
|
|
|
|
attributes:nil
|
|
|
|
|
|
error:NULL];
|
2025-11-20 14:27:57 +08:00
|
|
|
|
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"];
|
|
|
|
|
|
|
|
|
|
|
|
if (!hasCachedAssets) {
|
|
|
|
|
|
NSString *fileName = zipName;
|
|
|
|
|
|
if ([fileName hasPrefix:@"bundle://"]) {
|
|
|
|
|
|
fileName = [fileName substringFromIndex:[@"bundle://" length]];
|
|
|
|
|
|
}
|
|
|
|
|
|
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 *zipPath = nil;
|
|
|
|
|
|
if (dir.length > 0) {
|
|
|
|
|
|
zipPath = [bundle pathForResource:base ofType:ext inDirectory:dir];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
zipPath = [bundle pathForResource:base ofType:ext];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (zipPath.length == 0) {
|
|
|
|
|
|
if (error) {
|
2025-11-25 18:54:53 +08:00
|
|
|
|
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
|
|
|
|
|
code:KBSkinBridgeErrorZipMissing
|
|
|
|
|
|
userInfo:@{NSLocalizedDescriptionKey: @"Zip resource not found"}];
|
2025-11-20 14:27:57 +08:00
|
|
|
|
}
|
|
|
|
|
|
return NO;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSError *unzipError = nil;
|
|
|
|
|
|
BOOL ok = [SSZipArchive unzipFileAtPath:zipPath
|
|
|
|
|
|
toDestination:skinRoot
|
|
|
|
|
|
overwrite:YES
|
|
|
|
|
|
password:nil
|
|
|
|
|
|
error:&unzipError];
|
|
|
|
|
|
if (!ok || unzipError) {
|
|
|
|
|
|
if (error) {
|
2025-11-25 18:54:53 +08:00
|
|
|
|
*error = unzipError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
2025-11-20 14:27:57 +08:00
|
|
|
|
code:KBSkinBridgeErrorUnzipFailed
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
return NO;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 *subName in subItems) {
|
|
|
|
|
|
if ([subName isEqualToString:@"icons"] || [subName isEqualToString:@"__MACOSX"]) continue;
|
|
|
|
|
|
NSString *nestedRoot = [skinRoot stringByAppendingPathComponent:subName];
|
|
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSDictionary *shortNames = payload[kKBSkinPendingIconShortKey];
|
|
|
|
|
|
if (![shortNames isKindOfClass:NSDictionary.class] || shortNames.count == 0) {
|
|
|
|
|
|
shortNames = [self defaultIconShortNames];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
|
|
|
|
|
[shortNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *shortName, BOOL *stop) {
|
|
|
|
|
|
if (identifier.length == 0 || ![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 = [NSMutableDictionary dictionary];
|
|
|
|
|
|
themeJSON[@"id"] = skinId;
|
|
|
|
|
|
themeJSON[@"name"] = name ?: 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 ?: skinId];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!ok && error) {
|
2025-11-25 18:54:53 +08:00
|
|
|
|
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
|
2025-11-20 14:27:57 +08:00
|
|
|
|
code:KBSkinBridgeErrorApplyFailed
|
|
|
|
|
|
userInfo:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
return ok;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@end
|