Files
keyboard/keyBoard/Class/Manager/KBSkinService.m
2025-11-19 15:39:47 +08:00

494 lines
24 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.

//
// KBSkinService.m
// keyBoard
//
#import "KBSkinService.h"
#import "KBSkinManager.h"
#import "KBConfig.h"
#import "KBKeyboardPermissionManager.h"
#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", // 字母 q小写
@"letter_q_upper": @"key_q", // 字母 Q大写
@"letter_w_lower": @"key_w", // 字母 w小写
@"letter_w_upper": @"key_w", // 字母 W大写
@"letter_e_lower": @"key_e", // 字母 e小写
@"letter_e_upper": @"key_e", // 字母 E大写
@"letter_r_lower": @"key_r", // 字母 r小写
@"letter_r_upper": @"key_r", // 字母 R大写
@"letter_t_lower": @"key_t", // 字母 t小写
@"letter_t_upper": @"key_t", // 字母 T大写
@"letter_y_lower": @"key_y", // 字母 y小写
@"letter_y_upper": @"key_y", // 字母 Y大写
@"letter_u_lower": @"key_u", // 字母 u小写
@"letter_u_upper": @"key_u", // 字母 U大写
@"letter_i_lower": @"key_i", // 字母 i小写
@"letter_i_upper": @"key_i", // 字母 I大写
@"letter_o_lower": @"key_o", // 字母 o小写
@"letter_o_upper": @"key_o", // 字母 O大写
@"letter_p_lower": @"key_p", // 字母 p小写
@"letter_p_upper": @"key_p", // 字母 P大写
@"letter_a_lower": @"key_a", // 字母 a小写
@"letter_a_upper": @"key_a", // 字母 A大写
@"letter_s_lower": @"key_s", // 字母 s小写
@"letter_s_upper": @"key_s", // 字母 S大写
@"letter_d_lower": @"key_d", // 字母 d小写
@"letter_d_upper": @"key_d", // 字母 D大写
@"letter_f_lower": @"key_f", // 字母 f小写
@"letter_f_upper": @"key_f", // 字母 F大写
@"letter_g_lower": @"key_g", // 字母 g小写
@"letter_g_upper": @"key_g", // 字母 G大写
@"letter_h_lower": @"key_h", // 字母 h小写
@"letter_h_upper": @"key_h", // 字母 H大写
@"letter_j_lower": @"key_j", // 字母 j小写
@"letter_j_upper": @"key_j", // 字母 J大写
@"letter_k_lower": @"key_k", // 字母 k小写
@"letter_k_upper": @"key_k", // 字母 K大写
@"letter_l_lower": @"key_l", // 字母 l小写
@"letter_l_upper": @"key_l", // 字母 L大写
@"letter_z_lower": @"key_z", // 字母 z小写
@"letter_z_upper": @"key_z", // 字母 Z大写
@"letter_x_lower": @"key_x", // 字母 x小写
@"letter_x_upper": @"key_x", // 字母 X大写
@"letter_c_lower": @"key_c", // 字母 c小写
@"letter_c_upper": @"key_c", // 字母 C大写
@"letter_v_lower": @"key_v", // 字母 v小写
@"letter_v_upper": @"key_v", // 字母 V大写
@"letter_b_lower": @"key_b", // 字母 b小写
@"letter_b_upper": @"key_b", // 字母 B大写
@"letter_n_lower": @"key_n", // 字母 n小写
@"letter_n_upper": @"key_n", // 字母 N大写
@"letter_m_lower": @"key_m", // 字母 m小写
@"letter_m_upper": @"key_m", // 字母 M大写
// 数字键(数字面板顶行 1~0
@"digit_1": @"key_1", // 数字 1
@"digit_2": @"key_2", // 数字 2
@"digit_3": @"key_3", // 数字 3
@"digit_4": @"key_4", // 数字 4
@"digit_5": @"key_5", // 数字 5
@"digit_6": @"key_6", // 数字 6
@"digit_7": @"key_7", // 数字 7
@"digit_8": @"key_8", // 数字 8
@"digit_9": @"key_9", // 数字 9
@"digit_0": @"key_0", // 数字 0
// 常用符号123 页第二行 + 第三行)
@"sym_minus": @"key_minus", // '-'
@"sym_slash": @"key_slash", // '/'
@"sym_colon": @"key_colon", // ':'
@"sym_semicolon": @"key_semicolon",// ';'
@"sym_paren_l": @"key_paren_l", // '('
@"sym_paren_r": @"key_paren_r", // ')'
@"sym_dollar": @"key_dollar", // '$'
@"sym_amp": @"key_amp", // '&'
@"sym_at": @"key_at", // '@'
@"sym_quote_double": @"key_quote_d", // 双引号 "
@"sym_comma": @"key_comma", // ','
@"sym_dot": @"key_dot", // '.'
@"sym_question": @"key_question", // '?'
@"sym_exclam": @"key_exclam", // '!'
@"sym_quote_single": @"key_quote", // 单引号 '
// #+= 页额外符号(包括中括号/大括号等)
@"sym_bracket_l": @"key_bracket_l", // '['
@"sym_bracket_r": @"key_bracket_r", // ']'
@"sym_brace_l": @"key_brace_l", // '{'
@"sym_brace_r": @"key_brace_r", // '}'
@"sym_hash": @"key_hash", // '#'
@"sym_percent": @"key_percent", // '%'
@"sym_caret": @"key_caret", // '^'
@"sym_asterisk": @"key_asterisk", // '*'
@"sym_plus": @"key_plus", // '+'
@"sym_equal": @"key_equal", // '='
@"sym_underscore": @"key_underscore", // '_'
@"sym_backslash": @"key_backslash", // '\'
@"sym_pipe": @"key_pipe", // '|'
@"sym_tilde": @"key_tilde", // '~'
@"sym_lt": @"key_lt", // '<'
@"sym_gt": @"key_gt", // '>'
@"sym_euro": @"key_euro", // '€'
@"sym_pound": @"key_pound", // '£'
@"sym_bullet": @"key_bullet", // '•'
// 功能键(非字符输出)
@"space": @"key_space", // 空格键
@"backspace": @"key_del", // 删除键(⌫)
@"shift": @"key_up", // Shift
@"mode_123": @"key_123", // 字母面板左下角 "123"
@"mode_abc": @"key_abc", // 数字面板左下角 "abc"
@"symbols_toggle_more": @"key_symbols_more", // 数字面板内 "123 -> #+="
@"symbols_toggle_123": @"key_symbols_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]; });
return s;
}
- (void)applySkinWithJSON:(NSDictionary *)skinJSON
fromViewController:(UIViewController *)presenting
completion:(KBSkinApplyCompletion)completion {
if (skinJSON.count == 0) {
if (completion) completion(NO);
return;
}
// 1. 点击应用皮肤时,检查键盘启用 & 完全访问状态,并尽量给出友好提示。
KBKeyboardPermissionManager *perm = [KBKeyboardPermissionManager shared];
BOOL enabled = [perm isKeyboardEnabled];
KBFARecord fa = [perm lastKnownFullAccess];
BOOL hasFullAccess = (fa == KBFARecordGranted);
if (!enabled || !hasFullAccess) {
// 引导页(内部有自己的展示策略,避免过度打扰)
[perm presentPermissionIfNeededFrom:presenting];
// 简单提示:皮肤可以应用,但未开启完全访问时扩展无法读取 App Group 中的图片。
[KBHUD showInfo:KBLocalized(@"皮肤已应用,键盘需开启“允许完全访问”后才能显示图片")];
}
#if KB_SKIN_ICON_USE_REMOTE
[self kb_applySkinUsingRemoteIcons:skinJSON completion:completion];
#else
[self kb_applySkinUsingLocalIcons:skinJSON completion:completion];
#endif
}
#pragma mark - Internal helpers
/// 本地模式key_icons 的 value 为 Assets 名称(主 App bundle 内),导出到 App Group 后再写入主题。
- (void)kb_applySkinUsingLocalIcons:(NSDictionary *)skin completion:(KBSkinApplyCompletion)completion {
NSString *skinId = skin[@"id"] ?: @"local";
NSString *name = skin[@"name"] ?: skinId;
NSDictionary *iconNames = [skin[@"key_icons"] isKindOfClass:NSDictionary.class] ? skin[@"key_icons"] : @{};
NSMutableDictionary<NSString *, NSString *> *iconPathMap = [NSMutableDictionary dictionary];
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
if (containerURL && iconNames.count > 0) {
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];
[iconNames enumerateKeysAndObjectsUsingBlock:^(NSString *identifier, NSString *imageName, BOOL *stop) {
if (![imageName isKindOfClass:NSString.class] || imageName.length == 0) return;
UIImage *img = [UIImage imageNamed:imageName];
if (!img) return;
NSData *data = UIImagePNGRepresentation(img);
if (data.length == 0) return;
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;
}
}];
}
NSMutableDictionary *themeJSON = [skin mutableCopy];
if (iconPathMap.count > 0) {
themeJSON[@"key_icons"] = iconPathMap.copy; // value 改为 App Group 相对路径
}
// 先应用颜色 / hidden_keys / key_icons此时为相对路径扩展从 App Group 读取)
BOOL themeOK = [[KBSkinManager shared] applyThemeFromJSON:themeJSON];
// 再处理背景图片background_image与原逻辑一致
NSString *bgURL = skin[@"background_image"] ?: @"";
if (bgURL.length == 0) {
if (completion) completion(themeOK);
if (themeOK) {
[KBHUD showInfo:KBLocalized(@"已应用皮肤(无背景图)")];
} else {
[KBHUD showInfo:KBLocalized(@"应用皮肤失败")];
}
return;
}
[[KBNetworkManager shared] GET:bgURL parameters:nil headers:nil completion:^(id jsonOrData, NSURLResponse *response, NSError *error) {
NSData *data = ([jsonOrData isKindOfClass:NSData.class] ? (NSData *)jsonOrData : nil);
// 压缩尺寸,避免 Keychain 过大
if (data && 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;
}
}
dispatch_async(dispatch_get_main_queue(), ^{
NSData *payload = data;
if (payload.length == 0) {
// 兜底:生成一张简单渐变图片
CGSize size = CGSizeMake(1200, 600);
UIGraphicsBeginImageContextWithOptions(size, YES, 1.0);
CGContextRef ctx = UIGraphicsGetCurrentContext();
UIColor *c1 = [UIColor colorWithRed:0.76 green:0.91 blue:0.86 alpha:1];
UIColor *c2 = [UIColor colorWithRed:0.93 green:0.97 blue:0.91 alpha:1];
if ([skin[@"id"] hasPrefix:@"dark"]) {
c1 = [UIColor colorWithRed:0.1 green:0.12 blue:0.16 alpha:1];
c2 = [UIColor colorWithRed:0.22 green:0.24 blue:0.28 alpha:1];
}
CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB();
NSArray *colors = @[(__bridge id)c1.CGColor, (__bridge id)c2.CGColor];
CGFloat locs[] = {0,1};
CGGradientRef grad = CGGradientCreateWithColors(space, (__bridge CFArrayRef)colors, locs);
CGContextDrawLinearGradient(ctx, grad, CGPointZero, CGPointMake(size.width, size.height), 0);
CGGradientRelease(grad); CGColorSpaceRelease(space);
UIImage *img = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
payload = UIImageJPEGRepresentation(img, 0.9);
}
BOOL ok = (payload.length > 0)
? [[KBSkinManager shared] applyImageSkinWithData:payload skinId:skinId name:name]
: themeOK;
if (completion) completion(ok);
[KBHUD showInfo:(ok ? KBLocalized(@"已应用,切到键盘查看") : KBLocalized(@"应用皮肤失败"))];
});
}];
}
/// 远程 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 *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 BOOL zipOK = YES;
#if __has_include(<SSZipArchive/SSZipArchive.h>)
// 若本地尚未缓存该皮肤资源且提供了 zip_url则下载并解压 Zip 包。
// 支持两种来源:
// 1) 线上 URLhttp/https通过 KBNetworkManager 下载;
// 2) 本地测试zip_url 以 "bundle://" 前缀开头,例如 "bundle://001.zip"。
if (!hasCachedAssets && zipURL.length > 0) {
dispatch_group_enter(group);
void (^handleZipData)(NSData *) = ^(NSData *data) {
if (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;
dispatch_group_leave(group);
return;
}
// 兼容“额外包一层目录”的压缩结构:
// 若 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 *name in subItems) {
if ([name isEqualToString:@"icons"] || [name isEqualToString:@"__MACOSX"]) continue;
NSString *nestedRoot = [skinRoot stringByAppendingPathComponent:name];
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);
};
// 本地 bundle 测试zip_url 形如 "bundle://001.zip"
if ([zipURL hasPrefix:@"bundle://"]) {
NSString *name = [zipURL substringFromIndex:@"bundle://".length];
NSString *fileName = name ?: @"";
NSString *ext = fileName.pathExtension;
NSString *base = fileName;
if (ext.length == 0) {
ext = @"zip";
} else {
base = [fileName stringByDeletingPathExtension];
}
NSString *path = [[NSBundle mainBundle] pathForResource:base ofType:ext];
NSData *data = (path.length > 0) ? [NSData dataWithContentsOfFile:path] : nil;
handleZipData(data);
} else {
// 正常远程下载
[[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 (error || data.length == 0) {
zipOK = NO;
dispatch_group_leave(group);
return;
}
handleZipData(data);
}];
}
}
#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[@"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