添加扩展键盘本地皮肤
This commit is contained in:
39
Shared/KBSkinInstallBridge.h
Normal file
39
Shared/KBSkinInstallBridge.h
Normal file
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// KBSkinInstallBridge.h
|
||||
// 主 App 与键盘扩展共享的皮肤安装桥接工具。
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 跨进程通知:主 App 请求键盘扩展安装皮肤。
|
||||
extern NSString * const KBDarwinSkinInstallRequestNotification;
|
||||
|
||||
typedef void (^KBSkinInstallConsumeCompletion)(BOOL success, NSError * _Nullable error);
|
||||
|
||||
@interface KBSkinInstallBridge : NSObject
|
||||
|
||||
/// 默认图标短文件名映射(从 KBSkinIconMap.strings 读取)。
|
||||
+ (NSDictionary<NSString *, NSString *> *)defaultIconShortNames;
|
||||
|
||||
/// 主 App 侧:记录一个“从 bundle 解压皮肤”的请求,写入 App Group 并广播 Darwin 通知。
|
||||
+ (void)publishBundleSkinRequestWithId:(NSString *)skinId
|
||||
name:(NSString *)name
|
||||
zipName:(NSString *)zipName
|
||||
iconShortNames:(nullable NSDictionary<NSString *, NSString *> *)iconShortNames;
|
||||
|
||||
/// 读取当前 App Group 中待处理的请求。
|
||||
+ (nullable NSDictionary *)pendingRequestPayload;
|
||||
|
||||
/// 清理 App Group 中的请求记录。
|
||||
+ (void)clearPendingRequest;
|
||||
|
||||
/// 键盘扩展侧:如有请求则解压/应用皮肤。completion 中 success==YES 表示已处理并应用。
|
||||
+ (void)consumePendingRequestFromBundle:(NSBundle *)bundle
|
||||
completion:(nullable KBSkinInstallConsumeCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
283
Shared/KBSkinInstallBridge.m
Normal file
283
Shared/KBSkinInstallBridge.m
Normal file
@@ -0,0 +1,283 @@
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
||||
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (!containerURL) {
|
||||
if (error) {
|
||||
*error = [NSError errorWithDomain:kKBSkinBridgeErrorDomain
|
||||
code:KBSkinBridgeErrorContainerUnavailable
|
||||
userInfo:@{NSLocalizedDescriptionKey: @"App Group container unavailable"}];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user