Files
keyboard/Shared/KBSkinInstallBridge.m
2025-12-11 19:43:55 +08:00

692 lines
31 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.

//
// KBSkinInstallBridge.m
//
#import "KBSkinInstallBridge.h"
#import "KBConfig.h"
#import "KBSkinManager.h"
#if __has_include("KBNetworkManager.h")
#import "KBNetworkManager.h"
#endif
#if __has_include(<SSZipArchive/SSZipArchive.h>)
#import <SSZipArchive/SSZipArchive.h>
#endif
NSString * const KBDarwinSkinInstallRequestNotification = @"com.loveKey.nyx.skin.install.request";
NSErrorDomain const KBSkinBridgeErrorDomain = @"com.loveKey.nyx.skin.bridge";
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";
static NSString * const kKBSkinMetadataFileName = @"metadata.plist";
static NSString * const kKBSkinMetadataNameKey = @"name";
static NSString * const kKBSkinMetadataPreviewKey = @"preview";
static NSString * const kKBSkinMetadataZipKey = @"zip_url";
static NSString * const kKBSkinMetadataInstalledKey = @"installed_at";
@interface KBSkinDownloadRecord ()
- (instancetype)initWithSkinId:(NSString *)skinId metadata:(NSDictionary *)metadata;
@end
@implementation KBSkinDownloadRecord
- (instancetype)initWithSkinId:(NSString *)skinId metadata:(NSDictionary *)metadata {
if (self = [super init]) {
_skinId = skinId.length ? skinId : @"";
NSString *name = [metadata[kKBSkinMetadataNameKey] isKindOfClass:NSString.class] ? metadata[kKBSkinMetadataNameKey] : nil;
_name = name.length > 0 ? name : (_skinId.length ? _skinId : @"");
NSString *preview = [metadata[kKBSkinMetadataPreviewKey] isKindOfClass:NSString.class] ? metadata[kKBSkinMetadataPreviewKey] : nil;
_previewImage = preview.length > 0 ? preview : nil;
NSString *zip = [metadata[kKBSkinMetadataZipKey] isKindOfClass:NSString.class] ? metadata[kKBSkinMetadataZipKey] : nil;
_zipURL = zip.length > 0 ? zip : nil;
NSTimeInterval installed = 0;
id installObj = metadata[kKBSkinMetadataInstalledKey];
if ([installObj respondsToSelector:@selector(doubleValue)]) {
installed = [installObj doubleValue];
}
if (installed <= 0) {
installed = [[NSDate date] timeIntervalSince1970];
}
_installedAt = installed;
}
return self;
}
@end
@implementation KBSkinInstallBridge
+ (NSString *)kb_skinsRootPath {
NSFileManager *fm = [NSFileManager defaultManager];
NSURL *containerURL = [fm containerURLForSecurityApplicationGroupIdentifier:AppGroup];
NSString *root = containerURL.path;
if (root.length == 0) {
NSArray<NSString *> *dirs = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
root = dirs.firstObject ?: NSTemporaryDirectory();
}
return [root stringByAppendingPathComponent:@"Skins"];
}
+ (NSString *)kb_metadataPathForSkinId:(NSString *)skinId {
if (skinId.length == 0) { return nil; }
NSString *skinRoot = [[self kb_skinsRootPath] stringByAppendingPathComponent:skinId];
return [skinRoot stringByAppendingPathComponent:kKBSkinMetadataFileName];
}
+ (void)kb_storeMetadataForSkinId:(NSString *)skinId
name:(NSString *)name
preview:(NSString *)preview
zipURL:(NSString *)zipURL {
if (skinId.length == 0) { return; }
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
NSString *metaPath = [self kb_metadataPathForSkinId:skinId];
if (metaPath.length == 0) { return; }
NSFileManager *fm = [NSFileManager defaultManager];
NSString *dir = [metaPath stringByDeletingLastPathComponent];
[fm createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
NSTimeInterval now = [[NSDate date] timeIntervalSince1970];
NSMutableDictionary *dict = [NSMutableDictionary dictionary];
dict[@"id"] = skinId;
if (name.length > 0) {
dict[kKBSkinMetadataNameKey] = name;
}
if (preview.length > 0) {
dict[kKBSkinMetadataPreviewKey] = preview;
}
if (zipURL.length > 0) {
dict[kKBSkinMetadataZipKey] = zipURL;
}
dict[kKBSkinMetadataInstalledKey] = @(now);
[dict writeToFile:metaPath atomically:YES];
});
}
+ (NSArray<KBSkinDownloadRecord *> *)installedSkinRecords {
NSString *root = [self kb_skinsRootPath];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:root isDirectory:&isDir] || !isDir) {
return @[];
}
NSArray<NSString *> *entries = [fm contentsOfDirectoryAtPath:root error:NULL] ?: @[];
NSMutableArray<KBSkinDownloadRecord *> *records = [NSMutableArray array];
for (NSString *entry in entries) {
if (entry.length == 0 || [entry hasPrefix:@"."]) { continue; }
NSString *path = [root stringByAppendingPathComponent:entry];
BOOL isSubDir = NO;
if (![fm fileExistsAtPath:path isDirectory:&isSubDir] || !isSubDir) { continue; }
NSString *metaPath = [path stringByAppendingPathComponent:kKBSkinMetadataFileName];
NSDictionary *meta = [NSDictionary dictionaryWithContentsOfFile:metaPath] ?: @{};
KBSkinDownloadRecord *record = [[KBSkinDownloadRecord alloc] initWithSkinId:entry metadata:meta];
[records addObject:record];
}
[records sortUsingComparator:^NSComparisonResult(KBSkinDownloadRecord *obj1, KBSkinDownloadRecord *obj2) {
if (obj1.installedAt == obj2.installedAt) { return NSOrderedSame; }
return (obj1.installedAt > obj2.installedAt) ? NSOrderedAscending : NSOrderedDescending;
}];
return records.copy;
}
+ (BOOL)removeInstalledSkinWithId:(NSString *)skinId
error:(NSError * __autoreleasing *)error {
if (skinId.length == 0) { return YES; }
NSString *root = [self kb_skinsRootPath];
NSString *skinPath = [root stringByAppendingPathComponent:skinId];
NSFileManager *fm = [NSFileManager defaultManager];
BOOL isDir = NO;
if (![fm fileExistsAtPath:skinPath isDirectory:&isDir] || !isDir) {
return YES;
}
BOOL ok = [fm removeItemAtPath:skinPath error:error];
if (ok) {
NSString *currentId = [KBSkinManager shared].current.skinId;
if ([currentId isKindOfClass:NSString.class] && [currentId isEqualToString:skinId]) {
[[KBSkinManager shared] resetToDefault];
}
}
return ok;
}
+ (void)recordInstalledSkinWithId:(NSString *)skinId
name:(NSString *)name
preview:(NSString *)preview
zipURL:(NSString *)zipURL {
[self kb_storeMetadataForSkinId:skinId name:name preview:preview zipURL:zipURL];
}
+ (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];
}
+ (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;
// 标记在本次请求发起前是否已经有缓存资源(用于“有缓存但本次下载失败”时仍允许切换皮肤)。
BOOL hasCachedAssets = (contents.count > 0);
NSString *bgPath = [skinRoot stringByAppendingPathComponent:@"background.png"];
dispatch_group_t group = dispatch_group_create();
__block BOOL zipOK = YES;
__block BOOL didUnzip = NO; // 标记本次流程中是否成功解压过 Zip
__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;
}
// 标记已成功解压一次(即使 icons 目录结构需要后续整理)。
didUnzip = YES;
// 兼容“额外包一层目录”的压缩结构:
// 若 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
NSLog(@"[SkinBridge] will GET zip: %@", zipURL);
[KBHUD show];
[[KBNetworkManager shared] GETData:zipURL parameters:nil headers:nil completion:^(NSData *data, NSURLResponse *response, NSError *error) {
NSLog(@"[SkinBridge] GET finished, error = %@", error);
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(), ^{
// 若既没有预先存在的缓存资源,也没有在本次流程中成功解压出资源,
// 说明当前皮肤 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;
}
// 构造 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);
if (ok) {
NSString *preview = [skinJSON[@"preview"] isKindOfClass:NSString.class] ? skinJSON[@"preview"] : nil;
[self recordInstalledSkinWithId:skinId
name:name ?: skinId
preview:preview
zipURL:zipURL];
}
});
}
+ (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) {
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
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) {
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
code:KBSkinBridgeErrorInvalidPayload
userInfo:nil];
}
return NO;
}
// 皮肤根目录:优先 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];
}
}
if (baseRoot.length == 0) {
NSArray<NSString *> *dirs = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
baseRoot = dirs.firstObject ?: NSTemporaryDirectory();
}
NSString *skinsRoot = [baseRoot 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;
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) {
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
code:KBSkinBridgeErrorZipMissing
userInfo:@{NSLocalizedDescriptionKey: @"Zip resource not found"}];
}
return NO;
}
NSError *unzipError = nil;
BOOL ok = [SSZipArchive unzipFileAtPath:zipPath
toDestination:skinRoot
overwrite:YES
password:nil
error:&unzipError];
if (!ok || unzipError) {
if (error) {
*error = unzipError ?: [NSError errorWithDomain:KBSkinBridgeErrorDomain
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) {
*error = [NSError errorWithDomain:KBSkinBridgeErrorDomain
code:KBSkinBridgeErrorApplyFailed
userInfo:nil];
}
if (ok) {
[self recordInstalledSkinWithId:skinId
name:name ?: skinId
preview:nil
zipURL:zipName];
}
return ok;
#endif
}
@end