// // KBWebViewViewController.m // keyBoard // // Created by 张伟 on 2025/11/10. // #import "KBWebViewViewController.h" #import #import "KBConfig.h" #import "Masonry.h" @interface KBWebViewViewController () @property(nonatomic, strong) WKWebView * webView; // 🟢 顶部加载进度条 @property(nonatomic, strong) UIProgressView *progressView; @property(nonatomic, assign) BOOL observingProgress; @end @implementation KBWebViewViewController + (instancetype)legalViewControllerWithType:(KBLegalDocumentType)type { KBWebViewViewController *vc = [[KBWebViewViewController alloc] init]; vc.pageTitle = [self kb_titleForLegalDocumentType:type]; NSString *remoteURL = [self kb_remoteURLForLegalDocumentType:type]; if (remoteURL.length > 0) { vc.url = remoteURL; } else { vc.htmlString = [self kb_htmlForLegalDocumentType:type]; } return vc; } + (void)presentLegalDocumentType:(KBLegalDocumentType)type fromViewController:(UIViewController *)viewController { if (![viewController isKindOfClass:UIViewController.class]) { return; } KBWebViewViewController *vc = [self legalViewControllerWithType:type]; UINavigationController *nav = viewController.navigationController; if (nav) { [nav pushViewController:vc animated:YES]; return; } [viewController presentViewController:vc animated:YES completion:nil]; } + (nullable NSNumber *)legalDocumentTypeNumberFromQueryValue:(NSString *)queryValue { NSString *value = queryValue.lowercaseString ?: @""; if ([value isEqualToString:@"privacy"]) { return @(KBLegalDocumentTypePrivacyPolicy); } if ([value isEqualToString:@"membership"]) { return @(KBLegalDocumentTypeMembershipAgreement); } if ([value isEqualToString:@"terms"]) { return @(KBLegalDocumentTypeTermsOfService); } return nil; } + (NSString *)queryValueForLegalDocumentType:(KBLegalDocumentType)type { switch (type) { case KBLegalDocumentTypePrivacyPolicy: return @"privacy"; case KBLegalDocumentTypeMembershipAgreement: return @"membership"; case KBLegalDocumentTypeTermsOfService: default: return @"terms"; } } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; if (self.pageTitle.length > 0) { self.title = self.pageTitle; } } - (void)viewDidLoad { [super viewDidLoad]; self.view.backgroundColor = UIColor.whiteColor; [self configUI]; } - (void)configUI { // 1. 配置 JS 交互 WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; WKUserContentController *userContentController = [[WKUserContentController alloc] init]; [userContentController addScriptMessageHandler:self name:@"keyBoard"]; config.userContentController = userContentController; // 2. 创建 webView self.webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config]; self.webView.navigationDelegate = self; self.webView.backgroundColor = UIColor.clearColor; [self.view addSubview:self.webView]; [self.webView mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.kb_navView.mas_bottom); make.left.right.bottom.equalTo(self.view); }]; // 🟢 3. 顶部 2 像素进度条 self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault]; self.progressView.trackTintColor = [UIColor clearColor]; // 背景透明 self.progressView.progressTintColor = [UIColor greenColor]; self.progressView.progress = 0.0f; self.progressView.hidden = YES; // 初始隐藏 [self.view addSubview:self.progressView]; // 约束:贴在最上面,高度 2 像素,左右撑满 [self.progressView mas_makeConstraints:^(MASConstraintMaker *make) { make.top.equalTo(self.kb_navView.mas_bottom); make.left.right.equalTo(self.view); make.height.mas_equalTo(2.0); }]; // 🟢 4. 监听 WKWebView 加载进度(estimatedProgress) [self.webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil]; self.observingProgress = YES; // 5. 加载内容 if (self.htmlString.length > 0) { [self.webView loadHTMLString:self.htmlString baseURL:nil]; if (self.pageTitle.length > 0) { self.title = self.pageTitle; } return; } NSURL *URL = [NSURL URLWithString:self.url ?: @""]; if (!URL) { [self.webView loadHTMLString:[self.class kb_fallbackErrorHTML] baseURL:nil]; return; } NSURLRequest * req = [NSURLRequest requestWithURL:URL]; [self.webView loadRequest:req]; [self.view bringSubviewToFront:self.kb_navView]; [self.view bringSubviewToFront:self.progressView]; } #pragma mark - KVO: 监听加载进度(estimatedProgress) 🟢 - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (object == self.webView && [keyPath isEqualToString:@"estimatedProgress"]) { CGFloat progress = self.webView.estimatedProgress; // 0 ~ 1之间的进度 self.progressView.hidden = NO; [self.progressView setProgress:progress animated:YES]; if (progress >= 1.0f) { // 加载完成后延迟一点点再隐藏,顺滑一点 [UIView animateWithDuration:0.25 delay:0.25 options:UIViewAnimationOptionCurveEaseOut animations:^{ self.progressView.alpha = 0.0; } completion:^(BOOL finished) { self.progressView.progress = 0.0; self.progressView.hidden = YES; self.progressView.alpha = 1.0; }]; } } else { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } } #pragma mark - WKNavigationDelegate // 开始加载 - (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation { NSLog(@"开始加载url"); // 开始加载时,确保进度条出现 self.progressView.hidden = NO; self.progressView.progress = 0.0; } // 加载完成 - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation { NSLog(@"页面加载成功"); if (self.pageTitle.length > 0) { self.title = self.pageTitle; } else if (webView.title.length > 0) { self.title = webView.title; } } // 加载失败 - (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error { NSLog(@"webView load error: %@", error); // 失败时也把进度条收一下 self.progressView.hidden = YES; self.progressView.progress = 0.0; } #pragma mark - WKScriptMessageHandler (JS 调原生) - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if (![message.body isKindOfClass:NSDictionary.class]) { NSLog(@"Body参数不合法"); return; } [self handleJSMethodWithParams:message.body]; } - (void)handleJSMethodWithParams:(NSDictionary *)params { NSDictionary * body = [params copy]; NSLog(@"收到 JS 消息: %@", body); NSString * type = body[@"type"]; // 测试打印 if ([type isEqual:@"ONE_METHOD"]) { NSLog(@"come on baby js 调用你的方法了"); } // 修改title if ([type isEqual:@"changeTiele"]) { NSLog(@"%@", body); NSString * newTiele = body[@"payload"][@"data"]; self.title = newTiele; } } #pragma mark - Legal Content + (NSString *)kb_titleForLegalDocumentType:(KBLegalDocumentType)type { switch (type) { case KBLegalDocumentTypePrivacyPolicy: return KBLocalized(@"Privacy Policy"); case KBLegalDocumentTypeMembershipAgreement: return KBLocalized(@"Membership Agreement"); case KBLegalDocumentTypeTermsOfService: default: return KBLocalized(@"Agreement"); } } + (NSString *)kb_remoteURLForLegalDocumentType:(KBLegalDocumentType)type { switch (type) { case KBLegalDocumentTypePrivacyPolicy: return KB_PRIVACY_POLICY_URL; case KBLegalDocumentTypeMembershipAgreement: return KB_MEMBERSHIP_AGREEMENT_URL; case KBLegalDocumentTypeTermsOfService: default: return KB_TERMS_OF_SERVICE_URL; } } + (NSString *)kb_htmlForLegalDocumentType:(KBLegalDocumentType)type { NSString *title = [self kb_titleForLegalDocumentType:type]; NSString *body = @""; switch (type) { case KBLegalDocumentTypePrivacyPolicy: body = @"

Overview

This in-app privacy disclosure explains how the app and the custom keyboard handle data when you use account, AI, subscription, sync, and voice features.

Full Access

Network-based features inside the keyboard require Full Access. If you do not enable Full Access, those features stay unavailable.

Data Used For Features You Trigger

When you actively use AI reply, cloud sync, account, purchase verification, or voice input, the content required for that feature may be transmitted to the service provider to complete your request.

This may include typed text you choose to send, voice audio you record, account identifiers, email address, subscription status, and limited diagnostics needed for app functionality and fraud prevention.

Keyboard Boundaries

The custom keyboard does not operate in secure text fields and cannot access content in contexts where iOS blocks third-party keyboards.

Retention And Deletion

Account-related data is retained only as needed for app functionality, purchases, support, and legal compliance. Use the in-app account deletion flow to request account removal and associated cleanup.

Important

Replace this fallback page with your final published privacy policy URL before App Store submission so that the wording exactly matches App Store Connect privacy labels and your backend behavior.

"; break; case KBLegalDocumentTypeMembershipAgreement: body = @"

Subscription Terms

Paid membership unlocks subscription benefits for eligible premium features. Pricing, billing period, and any trial details are shown on the purchase sheet before you confirm payment.

Auto-Renewal

Subscriptions renew automatically unless cancelled at least 24 hours before the end of the current billing period. Renewal charges are handled by Apple through your App Store account.

Managing Your Subscription

You can restore purchases inside the app and manage or cancel subscriptions in Apple ID subscription settings after purchase.

Feature Availability

Some premium actions started from the custom keyboard may open the main app to complete login, purchase, or subscription management.

Important

Replace this fallback page with your final published membership agreement URL before App Store submission.

"; break; case KBLegalDocumentTypeTermsOfService: default: body = @"

Service Scope

This app provides a custom keyboard, account features, premium subscriptions, and optional AI-assisted and voice features. Some capabilities require network access and may open the main app to complete the flow.

Acceptable Use

You must not use the service to violate law, harass others, infringe rights, or generate abusive, sexual, hateful, or otherwise prohibited content.

AI And Voice Features

AI-generated or transcribed content may be inaccurate, incomplete, or inappropriate. You remain responsible for reviewing content before sending or relying on it.

Accounts And Purchases

You are responsible for activity performed through your account. Paid features are subject to Apple billing rules and any product limitations shown in the app.

Important

Replace this fallback page with your final published terms URL before App Store submission.

"; break; } return [NSString stringWithFormat:@"

%@

%@

Built-in fallback document. Configure the final public URL in KBConfig.h when release content is ready.

", title, body]; } + (NSString *)kb_fallbackErrorHTML { return @"

Page unavailable

The requested document could not be loaded.

"; } #pragma mark - Clean up 🟢 - (void)dealloc { if (self.observingProgress) { @try { [self.webView removeObserver:self forKeyPath:@"estimatedProgress"]; } @catch (NSException *exception) { NSLog(@"removeObserver estimatedProgress exception: %@", exception); } } // 顺便把 JS 通道也移除,避免潜在的循环引用 [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"keyBoard"]; } @end