1
This commit is contained in:
@@ -53,7 +53,7 @@
|
||||
// - hidden_keys:需要隐藏文字的按键 identifier
|
||||
// - key_icons:
|
||||
// * 当 KB_SKIN_ICON_USE_REMOTE==0 时,value 写本地 Assets 名称(如 "kb_space_melon")
|
||||
// * 当 KB_SKIN_ICON_USE_REMOTE==1 时,value 写远程图标 URL(如 "https://.../icons/space.png")
|
||||
// * 当 KB_SKIN_ICON_USE_REMOTE==1 时,value 写 Zip 包内的图标“短文件名”(如 "key_space";解压后路径为 Skins/<skinId>/icons/key_space.png)
|
||||
self.skins = @[
|
||||
@{
|
||||
@"id": @"melon",
|
||||
@@ -74,7 +74,7 @@
|
||||
@"space"
|
||||
],
|
||||
// 默认假设本地测试:这里的值写 Assets 名称。
|
||||
// 如果开启远程图标模式,可改为实际的图标 URL。
|
||||
// 如果开启远程 Zip 模式,可改为 Zip 内对应的图标短文件名(如 "key_space")。
|
||||
@"key_icons": @{
|
||||
// 字母键:大小写共用一套本地图(演示用)
|
||||
// 若后续需要不同图,只需改为 *_lower / *_upper 对应不同资源名即可。
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
[header addSubview:self.textView];
|
||||
|
||||
// 列表
|
||||
self.tableView = [[BaseTableView alloc] initWithFrame:CGRectMake(0, 60, KB_SCREEN_WIDTH, KB_SCREEN_HEIGHT - 60) style:UITableViewStyleInsetGrouped];
|
||||
self.tableView = [[BaseTableView alloc] initWithFrame:CGRectMake(0, KB_NAV_TOTAL_HEIGHT, KB_SCREEN_WIDTH, KB_SCREEN_HEIGHT - KB_NAV_TOTAL_HEIGHT) style:UITableViewStyleInsetGrouped];
|
||||
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.dataSource = self;
|
||||
|
||||
@@ -11,8 +11,63 @@
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBHUD.h"
|
||||
|
||||
#if __has_include(<SSZipArchive/SSZipArchive.h>)
|
||||
#import <SSZipArchive/SSZipArchive.h>
|
||||
#endif
|
||||
|
||||
@implementation KBSkinService
|
||||
|
||||
#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, ^{
|
||||
map = @{
|
||||
// 字母键(大小写共用)
|
||||
@"letter_q_lower": @"key_q", @"letter_q_upper": @"key_q",
|
||||
@"letter_w_lower": @"key_w", @"letter_w_upper": @"key_w",
|
||||
@"letter_e_lower": @"key_e", @"letter_e_upper": @"key_e",
|
||||
@"letter_r_lower": @"key_r", @"letter_r_upper": @"key_r",
|
||||
@"letter_t_lower": @"key_t", @"letter_t_upper": @"key_t",
|
||||
@"letter_y_lower": @"key_y", @"letter_y_upper": @"key_y",
|
||||
@"letter_u_lower": @"key_u", @"letter_u_upper": @"key_u",
|
||||
@"letter_i_lower": @"key_i", @"letter_i_upper": @"key_i",
|
||||
@"letter_o_lower": @"key_o", @"letter_o_upper": @"key_o",
|
||||
@"letter_p_lower": @"key_p", @"letter_p_upper": @"key_p",
|
||||
|
||||
@"letter_a_lower": @"key_a", @"letter_a_upper": @"key_a",
|
||||
@"letter_s_lower": @"key_s", @"letter_s_upper": @"key_s",
|
||||
@"letter_d_lower": @"key_d", @"letter_d_upper": @"key_d",
|
||||
@"letter_f_lower": @"key_f", @"letter_f_upper": @"key_f",
|
||||
@"letter_g_lower": @"key_g", @"letter_g_upper": @"key_g",
|
||||
@"letter_h_lower": @"key_h", @"letter_h_upper": @"key_h",
|
||||
@"letter_j_lower": @"key_j", @"letter_j_upper": @"key_j",
|
||||
@"letter_k_lower": @"key_k", @"letter_k_upper": @"key_k",
|
||||
@"letter_l_lower": @"key_l", @"letter_l_upper": @"key_l",
|
||||
|
||||
@"letter_z_lower": @"key_z", @"letter_z_upper": @"key_z",
|
||||
@"letter_x_lower": @"key_x", @"letter_x_upper": @"key_x",
|
||||
@"letter_c_lower": @"key_c", @"letter_c_upper": @"key_c",
|
||||
@"letter_v_lower": @"key_v", @"letter_v_upper": @"key_v",
|
||||
@"letter_b_lower": @"key_b", @"letter_b_upper": @"key_b",
|
||||
@"letter_n_lower": @"key_n", @"letter_n_upper": @"key_n",
|
||||
@"letter_m_lower": @"key_m", @"letter_m_upper": @"key_m",
|
||||
|
||||
// 功能键
|
||||
@"space": @"key_space", // 空格键
|
||||
@"backspace": @"key_del", // 删除键
|
||||
@"shift": @"key_up", // Shift(上箭头)
|
||||
@"mode_123": @"key_123", // 字母面板左下角 "123"
|
||||
@"ai": @"key_ai", // 自定义 AI 键
|
||||
@"return": @"key_send" // 发送/换行键
|
||||
};
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBSkinService *s; static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{ s = [KBSkinService new]; });
|
||||
@@ -154,84 +209,130 @@
|
||||
}];
|
||||
}
|
||||
|
||||
/// 远程模式:key_icons 的 value 为 URL,需要下载到 App Group,写入主题后再应用。
|
||||
/// 远程 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
|
||||
- (void)kb_applySkinUsingRemoteIcons:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion {
|
||||
NSString *skinId = skin[@"id"] ?: @"remote";
|
||||
NSString *name = skin[@"name"] ?: skinId;
|
||||
NSString *bgURL = skin[@"background_image"] ?: @"";
|
||||
NSDictionary *iconURLs = [skin[@"key_icons"] isKindOfClass:NSDictionary.class] ? skin[@"key_icons"] : @{};
|
||||
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"];
|
||||
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
__block NSData *bgData = nil;
|
||||
__block NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
|
||||
__block BOOL zipOK = YES;
|
||||
|
||||
// 背景图
|
||||
if (bgURL.length > 0) {
|
||||
#if __has_include(<SSZipArchive/SSZipArchive.h>)
|
||||
// 若本地尚未缓存该皮肤资源且提供了 zip_url,则下载并解压 Zip 包。
|
||||
if (!hasCachedAssets && zipURL.length > 0) {
|
||||
dispatch_group_enter(group);
|
||||
[[KBNetworkManager shared] GET:bgURL parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
|
||||
[[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 (data.length > 0) {
|
||||
UIImage *img = [UIImage imageWithData:data];
|
||||
if (img) {
|
||||
CGFloat maxW = 1500.0;
|
||||
if (img.size.width > maxW) {
|
||||
CGFloat scale = maxW / img.size.width;
|
||||
CGSize newSize = CGSizeMake(maxW, floor(img.size.height * scale));
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, YES, 1.0);
|
||||
[img drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
|
||||
UIImage *resized = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
img = resized ?: img;
|
||||
}
|
||||
data = UIImageJPEGRepresentation(img, 0.85) ?: data;
|
||||
}
|
||||
if (error || data.length == 0) {
|
||||
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;
|
||||
}
|
||||
bgData = data;
|
||||
dispatch_group_leave(group);
|
||||
}];
|
||||
}
|
||||
|
||||
// 图标下载到 App Group:Skins/<skinId>/icons/<identifier>.png
|
||||
[iconURLs enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *urlString, BOOL *stop) {
|
||||
if (![urlString isKindOfClass:NSString.class] || urlString.length == 0) return;
|
||||
dispatch_group_enter(group);
|
||||
[[KBNetworkManager shared] GET:urlString parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
|
||||
NSData *data = ([jsonOrData isKindOfClass:NSData.class] ? (NSData *)jsonOrData : nil);
|
||||
if (data.length > 0) {
|
||||
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (containerURL) {
|
||||
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];
|
||||
NSString *fileName = [NSString stringWithFormat:@"%@.png", identifier];
|
||||
NSString *fullPath = [iconsDir stringByAppendingPathComponent:fileName];
|
||||
if ([data writeToFile:fullPath atomically:YES]) {
|
||||
NSString *relative = [NSString stringWithFormat:@"Skins/%@/icons/%@", skinId, fileName];
|
||||
iconPathMap[identifier] = relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
dispatch_group_leave(group);
|
||||
}];
|
||||
}];
|
||||
#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[@"key_icons"] = iconPathMap.copy; // value 改为 App Group 相对路径
|
||||
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(@"应用皮肤失败"))];
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user