Files
keyboard/Shared/KBSkinInstallBridge.m
2025-11-20 18:23:56 +08:00

295 lines
12 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(<SSZipArchive/SSZipArchive.h>)
#import <SSZipArchive/SSZipArchive.h>
#endif
NSString * const KBDarwinSkinInstallRequestNotification = @"com.loveKey.nyx.skin.install.request";
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 kKBSkinBridgeErrorDomain = @"com.loveKey.nyx.skin.bridge";
typedef NS_ENUM(NSUInteger, KBSkinBridgeErrorCode) {
KBSkinBridgeErrorInvalidPayload = 1,
KBSkinBridgeErrorContainerUnavailable,
KBSkinBridgeErrorZipMissing,
KBSkinBridgeErrorUnzipFailed,
KBSkinBridgeErrorApplyFailed,
};
@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];
}
+ (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:kKBSkinBridgeErrorDomain
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:kKBSkinBridgeErrorDomain
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:kKBSkinBridgeErrorDomain
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:kKBSkinBridgeErrorDomain
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:kKBSkinBridgeErrorDomain
code:KBSkinBridgeErrorApplyFailed
userInfo:nil];
}
return ok;
#endif
}
@end