diff --git a/Example/SellyCloudSDK.xcodeproj/project.pbxproj b/Example/SellyCloudSDK.xcodeproj/project.pbxproj index d6e66dd..f2c9f6a 100644 --- a/Example/SellyCloudSDK.xcodeproj/project.pbxproj +++ b/Example/SellyCloudSDK.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0897849CBA2960C1F1BE2DC4 /* Pods_SellyCloudSDK_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3A5DE9B7559BAE46EA68112 /* Pods_SellyCloudSDK_Tests.framework */; }; + 0D0ECA322F83CFDC00917568 /* AVApiService.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D0ECA302F83CFDC00917568 /* AVApiService.m */; }; 236246B1275D4CD3B91DACE1 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 353107A80A9449DFB01DACE1 /* README.md */; }; 3C075C2B2E3873A800591B2D /* test1.png in Resources */ = {isa = PBXBuildFile; fileRef = 3C075C2A2E3873A800591B2D /* test1.png */; }; 3C0F91622EF39F0000680CB7 /* SCNavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C0F91612EF39F0000680CB7 /* SCNavigationController.m */; }; @@ -122,6 +123,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0D0ECA2F2F83CFDC00917568 /* AVApiService.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AVApiService.h; sourceTree = ""; }; + 0D0ECA302F83CFDC00917568 /* AVApiService.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AVApiService.m; sourceTree = ""; }; 11F5CE3EA9A94E55D1A35A8F /* SellyCloudSDK.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = SellyCloudSDK.podspec; path = ../SellyCloudSDK.podspec; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 18585A8A04A38911555AC335 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = ""; }; 28EA6D0E21FA7AAFE4E2C53C /* Pods-SellyCloudSDK_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SellyCloudSDK_Example.release.xcconfig"; path = "Target Support Files/Pods-SellyCloudSDK_Example/Pods-SellyCloudSDK_Example.release.xcconfig"; sourceTree = ""; }; @@ -314,6 +317,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0D0ECA312F83CFDC00917568 /* Network */ = { + isa = PBXGroup; + children = ( + 0D0ECA2F2F83CFDC00917568 /* AVApiService.h */, + 0D0ECA302F83CFDC00917568 /* AVApiService.m */, + ); + path = Network; + sourceTree = ""; + }; 3C075D132E3B474A00591B2D /* Play */ = { isa = PBXGroup; children = ( @@ -519,6 +531,7 @@ 6003F593195388D20070C39A /* Example for SellyCloudSDK */ = { isa = PBXGroup; children = ( + 0D0ECA312F83CFDC00917568 /* Network */, 3CC732F92EF0EEF4000027B2 /* Controllers */, 3CC732FE2EF0EEF4000027B2 /* Managers */, 3CC733052EF0EEF4000027B2 /* Utils */, @@ -984,6 +997,7 @@ 3C1851E02ECDE7690022F536 /* SellyCallPiPManager.m in Sources */, 3CC7335F2EF24A4C000027B2 /* SellyCallStatsView.m in Sources */, 3C4BF3132EC347510095F93A /* SellyVideoCallConferenceController.m in Sources */, + 0D0ECA322F83CFDC00917568 /* AVApiService.m in Sources */, 3CDB90672EFF908100FBC4E6 /* AVLiveStreamModel.m in Sources */, 3C8AC1F12EB85E4E000A58F1 /* SellyVideoCallViewController.m in Sources */, 3C312FFC2F021216006C90A4 /* AVLoginViewController.m in Sources */, diff --git a/Example/SellyCloudSDK/Controllers/AVUserManager.m b/Example/SellyCloudSDK/Controllers/AVUserManager.m index b856561..3d96831 100644 --- a/Example/SellyCloudSDK/Controllers/AVUserManager.m +++ b/Example/SellyCloudSDK/Controllers/AVUserManager.m @@ -4,15 +4,11 @@ // #import "AVUserManager.h" -#import +#import "AVApiService.h" static NSString * const kUserLoggedInKey = @"AVUserLoggedIn"; static NSString * const kUserUsernameKey = @"AVUserUsername"; -@interface AVUserManager () -@property (nonatomic, strong) AFHTTPSessionManager *sessionManager; -@end - @implementation AVUserManager + (instancetype)sharedManager { @@ -27,15 +23,8 @@ static NSString * const kUserUsernameKey = @"AVUserUsername"; - (instancetype)init { self = [super init]; if (self) { - // 从 UserDefaults 读取登录状态 _isLoggedIn = [[NSUserDefaults standardUserDefaults] boolForKey:kUserLoggedInKey]; _currentUsername = [[NSUserDefaults standardUserDefaults] stringForKey:kUserUsernameKey]; - - // 初始化网络请求管理器 - self.sessionManager = [AFHTTPSessionManager manager]; - self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer]; - self.sessionManager.requestSerializer = [AFJSONRequestSerializer serializer]; - self.sessionManager.requestSerializer.timeoutInterval = 10; } return self; } @@ -43,89 +32,24 @@ static NSString * const kUserUsernameKey = @"AVUserUsername"; - (void)loginWithUsername:(NSString *)username password:(NSString *)password completion:(void (^)(BOOL success, NSString * _Nullable errorMessage))completion { - - // 基本参数验证 if (username.length == 0) { - if (completion) { - completion(NO, @"用户名不能为空"); - } + if (completion) completion(NO, @"用户名不能为空"); return; } - if (password.length == 0) { - if (completion) { - completion(NO, @"密码不能为空"); - } + if (completion) completion(NO, @"密码不能为空"); return; } - - // 登录 API 端点 - NSString *loginURL = @"http://rtmp.sellycloud.io:8089/live/sdk/demo/login"; - - // 请求参数 - NSDictionary *parameters = @{ - @"username": username, - @"password": password - }; - - NSLog(@"🚀 开始登录请求: %@", username); - - // 发起 POST 请求 - [self.sessionManager POST:loginURL parameters:parameters headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) { - // 登录成功 - NSLog(@"✅ 登录成功,服务器响应: %@", responseObject); - - // 保存登录状态 - [self saveLoginStateWithUsername:username]; - - // 可以根据需要保存服务器返回的其他信息(如 token、userId 等) - // 例如: - // if (responseObject[@"token"]) { - // [[NSUserDefaults standardUserDefaults] setObject:responseObject[@"token"] forKey:@"AVUserToken"]; - // } - - // 回调成功 - if (completion) { - completion(YES, nil); + + [[AVApiService shared] loginWithUsername:username password:password + success:^(id responseObject) { + [self saveLoginStateWithUsername:username]; + if (completion) completion(YES, nil); } - - } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { - // 登录失败 - NSLog(@"❌ 登录失败: %@", error.localizedDescription); - - NSString *errorMessage = @"登录失败,请检查用户名和密码"; - - // 尝试获取 HTTP 状态码 - NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)task.response; - if (httpResponse) { - NSLog(@"❌ HTTP 状态码: %ld", (long)httpResponse.statusCode); - } - - // 尝试解析服务器返回的错误信息 - if (error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey]) { - NSData *errorData = error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey]; - NSDictionary *errorDict = [NSJSONSerialization JSONObjectWithData:errorData options:0 error:nil]; - - NSLog(@"❌ 服务器错误响应: %@", errorDict); - - // 尝试多种可能的错误信息字段 - if (errorDict[@"message"]) { - errorMessage = errorDict[@"message"]; - } else if (errorDict[@"error"]) { - errorMessage = errorDict[@"error"]; - } else if (errorDict[@"msg"]) { - errorMessage = errorDict[@"msg"]; - } - } else { - // 网络错误 - errorMessage = [NSString stringWithFormat:@"网络请求失败: %@", error.localizedDescription]; - } - - // 回调失败 - if (completion) { - completion(NO, errorMessage); - } - }]; + failure:^(NSError *error, NSString *serverMessage) { + NSString *msg = serverMessage ?: [NSString stringWithFormat:@"网络请求失败: %@", error.localizedDescription]; + if (completion) completion(NO, msg); + }]; } - (void)saveLoginStateWithUsername:(NSString *)username { diff --git a/Example/SellyCloudSDK/Controllers/Home/AVHomeViewController.m b/Example/SellyCloudSDK/Controllers/Home/AVHomeViewController.m index 45c93f4..b0b7f42 100644 --- a/Example/SellyCloudSDK/Controllers/Home/AVHomeViewController.m +++ b/Example/SellyCloudSDK/Controllers/Home/AVHomeViewController.m @@ -13,9 +13,7 @@ #import "AVLiveStreamModel.h" #import "AVLiveStreamCell.h" #import "SCPlayerConfigView.h" -#import -#import -#import +#import "AVApiService.h" @interface AVHomeViewController () @property (nonatomic, strong) UIView *headerView; @@ -23,13 +21,11 @@ @property (nonatomic, strong) UICollectionView *collectionView; @property (nonatomic, strong) NSArray *liveStreams; @property (nonatomic, strong) UIRefreshControl *refreshControl; -@property (nonatomic, strong) AFHTTPSessionManager *sessionManager; @end @implementation AVHomeViewController static NSString * const kLiveStreamCellIdentifier = @"LiveStreamCell"; -static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/sdk/alive-list"; - (void)viewDidLoad { [super viewDidLoad]; @@ -53,11 +49,6 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/ // 初始化数据 self.liveStreams = [NSMutableArray array]; - // 设置 AFNetworking - self.sessionManager = [AFHTTPSessionManager manager]; - self.sessionManager.responseSerializer = [AFJSONResponseSerializer serializer]; - self.sessionManager.requestSerializer.timeoutInterval = 10; - [self setupHeaderButtons]; [self setupCollectionView]; @@ -191,30 +182,20 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/ - (void)fetchLiveStreams { __weak typeof(self) weakSelf = self; - [self.sessionManager GET:kLiveListAPIURL parameters:nil headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, NSDictionary *responseObject) { + [[AVApiService shared] fetchLiveStreams:^(NSArray *streams) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - NSLog(@"%@",responseObject); [strongSelf.refreshControl endRefreshing]; - // 解析数据 - strongSelf.liveStreams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]]; - - NSLog(@"✅ 成功获取 %ld 个直播流", (long)strongSelf.liveStreams.count); - - // 刷新 UI + strongSelf.liveStreams = streams; + NSLog(@"✅ 成功获取 %ld 个直播流", (long)streams.count); [strongSelf.collectionView reloadData]; - - } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { + } failure:^(NSError *error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - [strongSelf.refreshControl endRefreshing]; - NSLog(@"❌ 网络请求失败: %@", error.localizedDescription); - - // 显示错误提示 UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"加载失败" - message:[NSString stringWithFormat:@"无法获取直播列表: %@", error.localizedDescription] + message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]]; [strongSelf presentViewController:alert animated:YES completion:nil]; @@ -349,6 +330,7 @@ static NSString * const kLiveListAPIURL = @"http://rtmp.sellycloud.io:8089/live/ liveStream.preview_image = @""; liveStream.duration = 0; liveStream.startTime = [[NSDate date] timeIntervalSince1970]; + liveStream.xorKey = config.xorKey; // 根据协议类型设置 playProtocol switch (config.protocol) { diff --git a/Example/SellyCloudSDK/Controllers/Home/AVLiveStreamCell.m b/Example/SellyCloudSDK/Controllers/Home/AVLiveStreamCell.m index 4368e91..9b7e4bd 100644 --- a/Example/SellyCloudSDK/Controllers/Home/AVLiveStreamCell.m +++ b/Example/SellyCloudSDK/Controllers/Home/AVLiveStreamCell.m @@ -17,6 +17,7 @@ @property (nonatomic, strong) UILabel *nameLabel; @property (nonatomic, strong) UILabel *liveLabel; @property (nonatomic, strong) UILabel *protocolLabel; +@property (nonatomic, strong) UIImageView *encryptIcon; @end @@ -141,6 +142,19 @@ make.centerY.equalTo(_liveLabel); make.height.equalTo(@16); make.width.greaterThanOrEqualTo(@40); + }]; + + // 加密图标 + _encryptIcon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"lock.shield.fill"]]; + _encryptIcon.tintColor = [UIColor systemOrangeColor]; + _encryptIcon.contentMode = UIViewContentModeScaleAspectFit; + _encryptIcon.hidden = YES; + [infoContainerView addSubview:_encryptIcon]; + + [_encryptIcon mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(_protocolLabel.mas_right).offset(6); + make.centerY.equalTo(_liveLabel); + make.width.height.equalTo(@20); make.right.lessThanOrEqualTo(infoContainerView); }]; } @@ -154,6 +168,9 @@ // 设置协议标签 _protocolLabel.text = model.play_protocol.uppercaseString; + + // 加密图标 + _encryptIcon.hidden = (model.xorKey.length == 0); // 使用 SDWebImage 加载预览图 if (model.preview_image.length > 0) { @@ -177,6 +194,7 @@ _nameLabel.text = @""; _durationLabel.text = @""; _protocolLabel.text = @""; + _encryptIcon.hidden = YES; } @end diff --git a/Example/SellyCloudSDK/Live/AVVideoConfiguration.h b/Example/SellyCloudSDK/Live/AVVideoConfiguration.h index f733249..35194fd 100644 --- a/Example/SellyCloudSDK/Live/AVVideoConfiguration.h +++ b/Example/SellyCloudSDK/Live/AVVideoConfiguration.h @@ -27,6 +27,9 @@ NS_ASSUME_NONNULL_BEGIN /// 视频编码器(H.264/H.265) @property (nonatomic, assign) AVVideoCodec codec; +/// XOR 加密密钥(十六进制字符串,如 @"AABBCCDD"),为空则不加密 +@property (nonatomic, copy, nullable) NSString *xorKey; + // ============ 工厂方法 ============ /// 创建默认配置 diff --git a/Example/SellyCloudSDK/Live/SCLivePusherViewController.m b/Example/SellyCloudSDK/Live/SCLivePusherViewController.m index dfaf559..03d67ec 100644 --- a/Example/SellyCloudSDK/Live/SCLivePusherViewController.m +++ b/Example/SellyCloudSDK/Live/SCLivePusherViewController.m @@ -18,8 +18,7 @@ #import "TokenGenerator.h" #import "AVLiveStreamModel.h" #import "AVConstants.h" -#import -#import +#import "AVApiService.h" @interface SCLivePusherViewController () @property (nonatomic, strong)UIView *liveView; @@ -196,6 +195,16 @@ [AVConfigManager.sharedManager saveConfig]; if (!self.isLiveStarted) { + // 校验加密密钥 + NSString *xorKeyError = [self validateXorKey:self.videoConfig.xorKey]; + if (xorKeyError) { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"密钥格式错误" + message:xorKeyError + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; + return; + } // 开始直播 [self startLive]; self.isLiveStarted = YES; @@ -449,7 +458,8 @@ NSString *token = [TokenGenerator generateStreamSignatureWithVhost:V_HOST appId:APP_ID channelId:self.videoConfig.streamId type:@"push" key:APP_SECRET]; self.livePusher.token = token; - + self.livePusher.xorKey = self.videoConfig.xorKey; + NSError *error; if (self.videoConfig.streamId) { error = [self.livePusher startLiveWithStreamId:self.videoConfig.streamId]; @@ -460,6 +470,31 @@ if (error) { NSLog(@"###startLive failed. error == %@",error.localizedDescription); } + + // 上报 XOR key 到服务器 + [self reportXorKey]; +} + +/// 校验 xorKey 格式,返回 nil 表示合法,返回错误信息表示不合法 +- (NSString *)validateXorKey:(NSString *)xorKey { + if (xorKey.length == 0) return nil; + if (xorKey.length % 2 != 0) { + return @"加密密钥必须为偶数长度"; + } + NSCharacterSet *hexChars = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"]; + if ([xorKey rangeOfCharacterFromSet:hexChars.invertedSet].location != NSNotFound) { + return @"加密密钥只能包含十六进制字符 (0-9, a-f, A-F)"; + } + return nil; +} + +- (void)reportXorKey { + [[AVApiService shared] reportXorKeyWithVhost:V_HOST + app:APP_ID + stream:self.videoConfig.streamId + xorKey:self.videoConfig.xorKey + success:nil + failure:nil]; } - (void)setupItemContainer { @@ -724,47 +759,27 @@ [self presentViewController:loadingAlert animated:YES completion:nil]; // 获取直播列表 - AFHTTPSessionManager *sessionManager = [AFHTTPSessionManager manager]; - sessionManager.responseSerializer = [AFJSONResponseSerializer serializer]; - sessionManager.requestSerializer.timeoutInterval = 10; - __weak typeof(self) weakSelf = self; - NSString *apiURL = @"http://rtmp.sellycloud.io:8089/live/sdk/alive-list"; - - [sessionManager GET:apiURL parameters:nil headers:nil progress:nil success:^(NSURLSessionDataTask * _Nonnull task, NSDictionary *responseObject) { + [[AVApiService shared] fetchLiveStreams:^(NSArray *streams) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - - // 关闭加载提示 [loadingAlert dismissViewControllerAnimated:YES completion:^{ - // 解析数据 - NSArray *liveStreams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]]; - - NSLog(@"✅ 获取 %ld 个直播流", (long)liveStreams.count); - - if (liveStreams.count == 0) { - // 没有直播流 + if (streams.count == 0) { UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示" message:@"暂无正在直播的用户" preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]]; [strongSelf presentViewController:alert animated:YES completion:nil]; } else { - // 显示直播流列表 - [strongSelf showStreamListWithStreams:liveStreams]; + [strongSelf showStreamListWithStreams:streams]; } }]; - - } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { + } failure:^(NSError *error) { __strong typeof(weakSelf) strongSelf = weakSelf; if (!strongSelf) return; - - // 关闭加载提示 [loadingAlert dismissViewControllerAnimated:YES completion:^{ - NSLog(@"❌ 获取直播列表失败: %@", error.localizedDescription); - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"加载失败" - message:[NSString stringWithFormat:@"无法获取直播列表: %@", error.localizedDescription] + message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]]; [strongSelf presentViewController:alert animated:YES completion:nil]; diff --git a/Example/SellyCloudSDK/Network/AVApiService.h b/Example/SellyCloudSDK/Network/AVApiService.h new file mode 100644 index 0000000..61b04b8 --- /dev/null +++ b/Example/SellyCloudSDK/Network/AVApiService.h @@ -0,0 +1,39 @@ +// +// AVApiService.h +// SellyCloudSDK_Example +// + +#import + +@class AVLiveStreamModel; + +NS_ASSUME_NONNULL_BEGIN + +typedef void(^AVApiSuccess)(id _Nullable responseObject); +typedef void(^AVApiFailure)(NSError *error); + +@interface AVApiService : NSObject + ++ (instancetype)shared; + +/// GET /live/sdk/alive-list +- (void)fetchLiveStreams:(void(^)(NSArray *streams))success + failure:(nullable AVApiFailure)failure; + +/// POST /live/sdk/demo/stream-xor +- (void)reportXorKeyWithVhost:(NSString *)vhost + app:(NSString *)app + stream:(NSString *)stream + xorKey:(nullable NSString *)xorKey + success:(nullable AVApiSuccess)success + failure:(nullable AVApiFailure)failure; + +/// POST /live/sdk/demo/login +- (void)loginWithUsername:(NSString *)username + password:(NSString *)password + success:(void(^)(id responseObject))success + failure:(void(^)(NSError *error, NSString * _Nullable serverMessage))failure; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Example/SellyCloudSDK/Network/AVApiService.m b/Example/SellyCloudSDK/Network/AVApiService.m new file mode 100644 index 0000000..101ae0e --- /dev/null +++ b/Example/SellyCloudSDK/Network/AVApiService.m @@ -0,0 +1,101 @@ +// +// AVApiService.m +// SellyCloudSDK_Example +// + +#import "AVApiService.h" +#import "AVLiveStreamModel.h" +#import +#import + +static NSString * const kBaseURL = @"http://rtmp.sellycloud.io:8089"; + +@interface AVApiService () +@property (nonatomic, strong) AFHTTPSessionManager *manager; +@end + +@implementation AVApiService + ++ (instancetype)shared { + static AVApiService *instance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[AVApiService alloc] init]; + }); + return instance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _manager = [AFHTTPSessionManager manager]; + _manager.requestSerializer = [AFJSONRequestSerializer serializer]; + _manager.responseSerializer = [AFJSONResponseSerializer serializer]; + _manager.requestSerializer.timeoutInterval = 10; + } + return self; +} + +- (void)fetchLiveStreams:(void(^)(NSArray *streams))success + failure:(nullable AVApiFailure)failure { + NSString *url = [NSString stringWithFormat:@"%@/live/sdk/alive-list", kBaseURL]; + [self.manager GET:url parameters:nil headers:nil progress:nil + success:^(NSURLSessionDataTask *task, id responseObject) { + NSLog(@"%@",responseObject); + NSArray *streams = [NSArray yy_modelArrayWithClass:AVLiveStreamModel.class json:responseObject[@"list"]]; + if (success) success(streams ?: @[]); + } failure:^(NSURLSessionDataTask *task, NSError *error) { + NSLog(@"[AVApiService] fetchLiveStreams failed: %@", error.localizedDescription); + if (failure) failure(error); + }]; +} + +- (void)reportXorKeyWithVhost:(NSString *)vhost + app:(NSString *)app + stream:(NSString *)stream + xorKey:(nullable NSString *)xorKey + success:(nullable AVApiSuccess)success + failure:(nullable AVApiFailure)failure { + NSDictionary *params = @{ + @"vhost": vhost ?: @"", + @"app": app ?: @"", + @"stream": stream ?: @"", + @"xor_key": xorKey ?: @"" + }; + NSString *url = [NSString stringWithFormat:@"%@/live/sdk/demo/stream-xor", kBaseURL]; + [self.manager POST:url parameters:params headers:nil progress:nil + success:^(NSURLSessionDataTask *task, id responseObject) { + NSLog(@"[AVApiService] reportXorKey success: %@", responseObject); + if (success) success(responseObject); + } failure:^(NSURLSessionDataTask *task, NSError *error) { + NSLog(@"[AVApiService] reportXorKey failed: %@", error.localizedDescription); + if (failure) failure(error); + }]; +} + +- (void)loginWithUsername:(NSString *)username + password:(NSString *)password + success:(void(^)(id responseObject))success + failure:(void(^)(NSError *error, NSString * _Nullable serverMessage))failure { + NSDictionary *params = @{ + @"username": username ?: @"", + @"password": password ?: @"" + }; + NSString *url = [NSString stringWithFormat:@"%@/live/sdk/demo/login", kBaseURL]; + [self.manager POST:url parameters:params headers:nil progress:nil + success:^(NSURLSessionDataTask *task, id responseObject) { + NSLog(@"[AVApiService] login success"); + if (success) success(responseObject); + } failure:^(NSURLSessionDataTask *task, NSError *error) { + NSLog(@"[AVApiService] login failed: %@", error.localizedDescription); + NSString *serverMessage = nil; + NSData *errorData = error.userInfo[AFNetworkingOperationFailingURLResponseDataErrorKey]; + if (errorData) { + NSDictionary *errorDict = [NSJSONSerialization JSONObjectWithData:errorData options:0 error:nil]; + serverMessage = errorDict[@"message"] ?: errorDict[@"error"] ?: errorDict[@"msg"]; + } + if (failure) failure(error, serverMessage); + }]; +} + +@end diff --git a/Example/SellyCloudSDK/Play/AVLiveStreamModel.h b/Example/SellyCloudSDK/Play/AVLiveStreamModel.h index 1197374..20706a7 100644 --- a/Example/SellyCloudSDK/Play/AVLiveStreamModel.h +++ b/Example/SellyCloudSDK/Play/AVLiveStreamModel.h @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) NSTimeInterval duration; @property (nonatomic, copy) NSString *preview_image; // 预览图 URL @property (nonatomic, copy) NSString *play_protocol; // 播放协议:rtc, rtmp, flv, hls 等 +@property (nonatomic, copy, nullable) NSString *xorKey; // XOR 解密密钥 (十六进制字符串) - (NSString *)displayName; - (NSString *)durationString; diff --git a/Example/SellyCloudSDK/Play/AVLiveStreamModel.m b/Example/SellyCloudSDK/Play/AVLiveStreamModel.m index d90273b..0b92333 100644 --- a/Example/SellyCloudSDK/Play/AVLiveStreamModel.m +++ b/Example/SellyCloudSDK/Play/AVLiveStreamModel.m @@ -7,6 +7,12 @@ @implementation AVLiveStreamModel ++ (NSDictionary *)modelCustomPropertyMapper { + return @{ + @"xorKey" : @"xor_key" + }; +} + - (NSString *)displayName { return self.stream.length > 0 ? self.stream : @"未知流"; } diff --git a/Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m b/Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m index f2b0723..9b8d4a5 100644 --- a/Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m +++ b/Example/SellyCloudSDK/Play/SCLiveVideoPlayerViewController.m @@ -387,6 +387,7 @@ NSString *token = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:config.streamId type:@"pull" key:APP_SECRET]; SellyLiveVideoPlayer *player = [[SellyLiveVideoPlayer alloc] init]; player.token = token; + player.xorKey = self.streamModel.xorKey ?: config.xorKey; player.delegate = self; player.scaleMode = SellyPlayerScalingModeAspectFit; [player setRenderView:self.playerContainerView]; @@ -405,6 +406,7 @@ NSString *pkToken = [TokenGenerator generateStreamSignatureWithVhost:self.streamModel.vhost appId:self.streamModel.app channelId:self.pkConfig.streamId type:@"pull" key:APP_SECRET]; SellyLiveVideoPlayer *pkPlayer = [[SellyLiveVideoPlayer alloc] init]; pkPlayer.token = pkToken; + pkPlayer.xorKey = self.streamModel.xorKey ?: self.currentConfig.xorKey; pkPlayer.delegate = self; pkPlayer.scaleMode = SellyPlayerScalingModeAspectFit; [pkPlayer setRenderView:self.pkPlayerContainerView]; diff --git a/Example/SellyCloudSDK/Play/SCPlayerConfigView.h b/Example/SellyCloudSDK/Play/SCPlayerConfigView.h index 4e8cd80..2326bd1 100644 --- a/Example/SellyCloudSDK/Play/SCPlayerConfigView.h +++ b/Example/SellyCloudSDK/Play/SCPlayerConfigView.h @@ -14,6 +14,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SCPlayerConfig : NSObject @property (nonatomic, assign) SellyLiveMode protocol; @property (nonatomic, strong) NSString *streamId; +@property (nonatomic, copy, nullable) NSString *xorKey; /// 保存配置到 UserDefaults - (void)saveToUserDefaults; diff --git a/Example/SellyCloudSDK/Play/SCPlayerConfigView.m b/Example/SellyCloudSDK/Play/SCPlayerConfigView.m index 2251454..b7d6b0e 100644 --- a/Example/SellyCloudSDK/Play/SCPlayerConfigView.m +++ b/Example/SellyCloudSDK/Play/SCPlayerConfigView.m @@ -12,6 +12,7 @@ // 配置保存的 key static NSString * const kSCPlayerConfigProtocol = @"SCPlayerConfigProtocol"; static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; +static NSString * const kSCPlayerConfigXorKey = @"SCPlayerConfigXorKey"; @implementation SCPlayerConfig @@ -21,6 +22,11 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; if (self.streamId) { [defaults setObject:self.streamId forKey:kSCPlayerConfigStreamId]; } + if (self.xorKey.length > 0) { + [defaults setObject:self.xorKey forKey:kSCPlayerConfigXorKey]; + } else { + [defaults removeObjectForKey:kSCPlayerConfigXorKey]; + } [defaults synchronize]; } @@ -39,7 +45,10 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; // 加载 streamId NSString *streamId = [defaults objectForKey:kSCPlayerConfigStreamId]; config.streamId = streamId ? streamId : @"test"; - + + // 加载 xorKey + config.xorKey = [defaults objectForKey:kSCPlayerConfigXorKey]; + return config; } @@ -50,6 +59,7 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; @property (nonatomic, strong) UIVisualEffectView *backgroundView; @property (nonatomic, strong) UISegmentedControl *protocolSegment; @property (nonatomic, strong) UITextField *streamIdField; +@property (nonatomic, strong) UITextField *xorKeyField; @property (nonatomic, strong) UIButton *playButton; @property (nonatomic, copy) void(^callback)(SCPlayerConfig *config); @property (nonatomic, strong) MASConstraint *backgroundViewCenterYConstraint; // 保存约束引用 @@ -172,6 +182,35 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; make.height.offset(44); }]; + // 加密密钥标签 + UILabel *xorKeyLabel = [[UILabel alloc] init]; + xorKeyLabel.text = @"加密密钥 (Hex),留空不解密"; + xorKeyLabel.font = [UIFont systemFontOfSize:14]; + xorKeyLabel.numberOfLines = 0; + [_contentView addSubview:xorKeyLabel]; + [xorKeyLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(_streamIdField.mas_bottom).offset(24); + make.left.right.equalTo(_contentView); + make.height.offset(20); + }]; + + // 加密密钥输入框 + _xorKeyField = [[UITextField alloc] init]; + _xorKeyField.placeholder = @"如 AABBCCDD"; + _xorKeyField.borderStyle = UITextBorderStyleRoundedRect; + _xorKeyField.font = [UIFont systemFontOfSize:15]; + _xorKeyField.clearButtonMode = UITextFieldViewModeWhileEditing; + _xorKeyField.returnKeyType = UIReturnKeyDone; + _xorKeyField.delegate = self; + _xorKeyField.autocapitalizationType = UITextAutocapitalizationTypeNone; + _xorKeyField.autocorrectionType = UITextAutocorrectionTypeNo; + [_contentView addSubview:_xorKeyField]; + [_xorKeyField mas_makeConstraints:^(MASConstraintMaker *make) { + make.top.equalTo(xorKeyLabel.mas_bottom).offset(8); + make.left.right.equalTo(_contentView); + make.height.offset(44); + }]; + // 播放按钮 _playButton = [UIButton buttonWithType:UIButtonTypeSystem]; [_playButton setTitle:@"开始播放" forState:UIControlStateNormal]; @@ -183,7 +222,7 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; [_playButton addTarget:self action:@selector(playButtonTapped) forControlEvents:UIControlEventTouchUpInside]; [_contentView addSubview:_playButton]; [_playButton mas_makeConstraints:^(MASConstraintMaker *make) { - make.top.equalTo(_streamIdField.mas_bottom).offset(32); + make.top.equalTo(_xorKeyField.mas_bottom).offset(32); make.left.right.equalTo(_contentView); make.height.offset(50); make.bottom.equalTo(_contentView); @@ -201,10 +240,24 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; return; } + NSString *xorKey = [_xorKeyField.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (xorKey.length > 0) { + if (xorKey.length % 2 != 0) { + [self showAlertWithMessage:@"加密密钥必须为偶数长度"]; + return; + } + NSCharacterSet *hexChars = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"]; + if ([xorKey rangeOfCharacterFromSet:hexChars.invertedSet].location != NSNotFound) { + [self showAlertWithMessage:@"加密密钥只能包含十六进制字符 (0-9, a-f, A-F)"]; + return; + } + } + SCPlayerConfig *config = [[SCPlayerConfig alloc] init]; config.protocol = _protocolSegment.selectedSegmentIndex == 0 ? SellyLiveMode_RTMP : SellyLiveMode_RTC; config.streamId = streamId; - + config.xorKey = xorKey.length > 0 ? xorKey : nil; + // 保存配置 [config saveToUserDefaults]; @@ -306,6 +359,9 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; if (savedConfig.streamId && savedConfig.streamId.length > 0) { _streamIdField.text = savedConfig.streamId; } + + // 设置 xorKey + _xorKeyField.text = savedConfig.xorKey ?: @""; } #pragma mark - UITextFieldDelegate @@ -340,8 +396,9 @@ static NSString * const kSCPlayerConfigStreamId = @"SCPlayerConfigStreamId"; NSTimeInterval duration = [userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]; UIViewAnimationCurve curve = [userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue]; - // 计算输入框在屏幕上的位置 - CGRect textFieldFrame = [_streamIdField convertRect:_streamIdField.bounds toView:self]; + // 计算当前激活输入框在屏幕上的位置 + UITextField *activeField = _xorKeyField.isFirstResponder ? _xorKeyField : _streamIdField; + CGRect textFieldFrame = [activeField convertRect:activeField.bounds toView:self]; CGFloat textFieldBottom = CGRectGetMaxY(textFieldFrame); // 计算需要的偏移量 diff --git a/Example/SellyCloudSDK/Views/AVSettingsView.h b/Example/SellyCloudSDK/Views/AVSettingsView.h index 9a623e1..73ee7bc 100644 --- a/Example/SellyCloudSDK/Views/AVSettingsView.h +++ b/Example/SellyCloudSDK/Views/AVSettingsView.h @@ -15,10 +15,11 @@ typedef NS_OPTIONS(NSUInteger, AVSettingsFieldMask) { AVSettingsFieldStreamId = 1 << 0, // Show Stream ID field AVSettingsFieldNickname = 1 << 1, // Show Nickname field AVSettingsFieldVideoParams = 1 << 2, // Show video params (codec, resolution, fps, bitrate) + AVSettingsFieldXorKey = 1 << 3, // Show XOR encryption key field // Convenient combinations AVSettingsFieldBasicPull = AVSettingsFieldStreamId | AVSettingsFieldNickname, // For pull page - AVSettingsFieldAll = AVSettingsFieldStreamId | AVSettingsFieldNickname | AVSettingsFieldVideoParams // For push page + AVSettingsFieldAll = AVSettingsFieldStreamId | AVSettingsFieldNickname | AVSettingsFieldVideoParams | AVSettingsFieldXorKey // For push page }; @interface AVSettingsView : UIView diff --git a/Example/SellyCloudSDK/Views/AVSettingsView.m b/Example/SellyCloudSDK/Views/AVSettingsView.m index 310f255..2e53887 100644 --- a/Example/SellyCloudSDK/Views/AVSettingsView.m +++ b/Example/SellyCloudSDK/Views/AVSettingsView.m @@ -21,6 +21,7 @@ @property (nonatomic, strong) UITextField *fpsField; @property (nonatomic, strong) UITextField *maxBitrateField; @property (nonatomic, strong) UITextField *minBitrateField; +@property (nonatomic, strong) UITextField *xorKeyField; @property (nonatomic, strong) NSLayoutConstraint *containerCenterYConstraint; @property (nonatomic, weak) UITextField *activeTextField; // 当前激活的输入框 @@ -194,6 +195,37 @@ topOffset += 40 + spacing; } + // XOR Key (after nickname, before video params) + if (self.fieldsMask & AVSettingsFieldXorKey) { + UILabel *xorKeyLabel = [[UILabel alloc] init]; + xorKeyLabel.text = @"加密密钥 (Hex)"; + xorKeyLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium]; + [contentView addSubview:xorKeyLabel]; + xorKeyLabel.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [xorKeyLabel.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:20], + [xorKeyLabel.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:topOffset] + ]]; + topOffset += 20; + + _xorKeyField = [[UITextField alloc] init]; + _xorKeyField.placeholder = @"如 AABBCCDD(留空不加密)"; + _xorKeyField.borderStyle = UITextBorderStyleRoundedRect; + _xorKeyField.autocapitalizationType = UITextAutocapitalizationTypeNone; + _xorKeyField.autocorrectionType = UITextAutocorrectionTypeNo; + _xorKeyField.delegate = self; + _xorKeyField.returnKeyType = UIReturnKeyDone; + [contentView addSubview:_xorKeyField]; + _xorKeyField.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [_xorKeyField.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:20], + [_xorKeyField.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor constant:-20], + [_xorKeyField.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:topOffset], + [_xorKeyField.heightAnchor constraintEqualToConstant:40] + ]]; + topOffset += 40 + spacing; + } + // Video parameters (conditional) if (self.fieldsMask & AVSettingsFieldVideoParams) { // Resolution @@ -373,6 +405,14 @@ if (_minBitrateField) { _minBitrateField.text = [NSString stringWithFormat:@"%ld", (long)(config.videoMinBitRate / 1000)]; } + if (_xorKeyField) { + // Load from config first, fallback to cached value + NSString *key = config.xorKey; + if (key.length == 0) { + key = [[NSUserDefaults standardUserDefaults] stringForKey:@"selly_xor_key_cache"]; + } + _xorKeyField.text = key ?: @""; + } self.frame = viewController.view.bounds; self.alpha = 0; @@ -428,6 +468,17 @@ self.tempConfig.videoMinBitRate = minBitrate * 1000; // Convert kbps to bps } + if (_xorKeyField) { + NSString *key = [_xorKeyField.text stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + self.tempConfig.xorKey = key.length > 0 ? key : nil; + // Cache for next time + if (key.length > 0) { + [[NSUserDefaults standardUserDefaults] setObject:key forKey:@"selly_xor_key_cache"]; + } else { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"selly_xor_key_cache"]; + } + } + if (self.callback) { self.callback(self.tempConfig); } diff --git a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPlayer.h b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPlayer.h index cd38f7d..7bfbbff 100644 --- a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPlayer.h +++ b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPlayer.h @@ -38,6 +38,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign)SellyPlayerScalingMode scaleMode; //token @property (nonatomic, strong)NSString *token; +/// XOR 解密密钥 (十六进制字符串, 如 @"ABCDEF12") +/// 必须为偶数长度且仅包含 0-9, a-f, A-F,传入非法值将抛出 NSInvalidArgumentException 导致 App 崩溃。 +/// 传 nil 或空字符串表示不解密。 +@property (nonatomic, copy, nullable)NSString *xorKey; @end diff --git a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPusher.h b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPusher.h index cdd6dd2..7456d56 100644 --- a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPusher.h +++ b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Headers/SellyLiveVideoPusher.h @@ -117,6 +117,10 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign)SellyLiveVideoConfiguration *videoConfig; //token @property (nonatomic, strong)NSString *token; +/// XOR 加密密钥 (十六进制字符串, 如 @"ABCDEF12") +/// 必须为偶数长度且仅包含 0-9, a-f, A-F,传入非法值将抛出 NSInvalidArgumentException 导致 App 崩溃。 +/// 传 nil 或空字符串表示不加密。 +@property (nonatomic, copy, nullable)NSString *xorKey; /// 是否启用 Center Stage(人物居中),默认 NO /// 支持运行时动态切换,不支持的设备设置无效 diff --git a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Modules/SellyCloudSDK.swiftmodule/arm64-apple-ios.swiftmodule b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Modules/SellyCloudSDK.swiftmodule/arm64-apple-ios.swiftmodule index 3c5c446..58b4d25 100644 Binary files a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Modules/SellyCloudSDK.swiftmodule/arm64-apple-ios.swiftmodule and b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/Modules/SellyCloudSDK.swiftmodule/arm64-apple-ios.swiftmodule differ diff --git a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/SellyCloudSDK b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/SellyCloudSDK index 355af3c..c9c0976 100755 Binary files a/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/SellyCloudSDK and b/Example/SubModules/SellyCloudSDK/SellyCloudSDK/sdk/SellyCloudSDK.framework/SellyCloudSDK differ