From e067531a3fb9ef9245d18ba1c6344fddbdf119f0 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Sat, 28 Mar 2026 20:04:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EC=BA=A1=EC=B3=90=20=EB=B3=B4=ED=98=B8=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EA=B3=A0=20=EB=B9=84=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8=EB=A5=BC=20=EC=9C=A0=EC=A7=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Live/Room/V2/LiveRoomViewV2.swift | 90 +++++++++++++++++-- docs/20260328_방장캡쳐녹화허용.md | 38 ++++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 docs/20260328_방장캡쳐녹화허용.md diff --git a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift index 885d931..e6ac829 100644 --- a/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift +++ b/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift @@ -61,9 +61,21 @@ struct LiveRoomViewV2: View { return I18n.LiveRoom.chatFreezeBlockedMessage } + + private var isCurrentUserHost: Bool { + guard let creatorId = viewModel.liveRoomInfo?.creatorId else { + return false + } + + return creatorId == UserDefaults.int(forKey: .userId) + } + + private var shouldEnforceScreenCaptureProtection: Bool { + !isCurrentUserHost + } var body: some View { - ScreenCaptureSecureContainer { + ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection) { ZStack { Color.black.edgesIgnoringSafeArea(.all) @@ -533,8 +545,7 @@ struct LiveRoomViewV2: View { UIApplication.shared.isIdleTimerDisabled = true UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) viewModel.initAgoraEngine() - // 진입 시 현재 캡쳐 상태를 즉시 동기화해 첫 프레임부터 보호 상태를 반영 - applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured) + syncScreenCaptureProtectionState() viewModel.getMemberCan() viewModel.getRoomInfo() @@ -928,7 +939,10 @@ struct LiveRoomViewV2: View { } // 시스템 캡쳐 상태 변경(녹화 시작/종료 등)에 맞춰 보호 로직 갱신 .onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in - applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured) + syncScreenCaptureProtectionState() + } + .onChange(of: viewModel.liveRoomInfo?.creatorId) { _ in + syncScreenCaptureProtectionState() } .onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in if isFrozen { @@ -1107,18 +1121,26 @@ struct LiveRoomViewV2: View { // 스크린샷/화면 녹화 노출 보호를 위해 SwiftUI 콘텐츠를 보안 컨테이너에 탑재 private struct ScreenCaptureSecureContainer: UIViewControllerRepresentable { + let isSecureModeEnabled: Bool let content: Content - init(@ViewBuilder content: () -> Content) { + init(isSecureModeEnabled: Bool = true, @ViewBuilder content: () -> Content) { + self.isSecureModeEnabled = isSecureModeEnabled self.content = content() } func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController { - ScreenCaptureSecureHostingController(rootView: content) + ScreenCaptureSecureHostingController( + rootView: content, + isSecureModeEnabled: isSecureModeEnabled + ) } func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController, context: Context) { - uiViewController.update(rootView: content) + uiViewController.update( + rootView: content, + isSecureModeEnabled: isSecureModeEnabled + ) } } @@ -1126,9 +1148,11 @@ private struct ScreenCaptureSecureContainer: UIViewControllerRepr private final class ScreenCaptureSecureHostingController: UIViewController { private let secureContainerView = ScreenCaptureSecureView() private let hostingController: UIHostingController + private var isSecureModeEnabled: Bool - init(rootView: Content) { + init(rootView: Content, isSecureModeEnabled: Bool) { hostingController = UIHostingController(rootView: rootView) + self.isSecureModeEnabled = isSecureModeEnabled super.init(nibName: nil, bundle: nil) } @@ -1145,12 +1169,15 @@ private final class ScreenCaptureSecureHostingController: UIViewC hostingController.view.backgroundColor = .clear addChild(hostingController) + secureContainerView.setSecureModeEnabled(isSecureModeEnabled) secureContainerView.embed(contentView: hostingController.view) hostingController.didMove(toParent: self) } - func update(rootView: Content) { + func update(rootView: Content, isSecureModeEnabled: Bool) { + self.isSecureModeEnabled = isSecureModeEnabled hostingController.rootView = rootView + secureContainerView.setSecureModeEnabled(isSecureModeEnabled) secureContainerView.embed(contentView: hostingController.view) } } @@ -1161,6 +1188,7 @@ private final class ScreenCaptureSecureView: UIView { private weak var secureContentView: UIView? private let failClosedOverlayView = UIView() private var didLogFailClosedActivation = false + private var isSecureModeEnabled = true override init(frame: CGRect) { super.init(frame: frame) @@ -1207,6 +1235,25 @@ private final class ScreenCaptureSecureView: UIView { } func embed(contentView: UIView) { + if !isSecureModeEnabled { + updateFailClosedState(isActive: false) + + guard contentView.superview !== self else { + return + } + + contentView.removeFromSuperview() + contentView.translatesAutoresizingMaskIntoConstraints = false + insertSubview(contentView, belowSubview: failClosedOverlayView) + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + return + } + if secureContentView == nil { secureContentView = resolveSecureContentView() } @@ -1235,6 +1282,21 @@ private final class ScreenCaptureSecureView: UIView { ]) } + func setSecureModeEnabled(_ isEnabled: Bool) { + isSecureModeEnabled = isEnabled + secureTextField.isHidden = !isEnabled + + if isEnabled { + if secureContentView == nil { + secureContentView = resolveSecureContentView() + } + updateFailClosedState(isActive: secureContentView == nil) + return + } + + updateFailClosedState(isActive: false) + } + private func resolveSecureContentView() -> UIView? { secureTextField.subviews.first { let className = NSStringFromClass(type(of: $0)) @@ -1266,6 +1328,16 @@ private final class ScreenCaptureSecureTextField: UITextField { } private extension LiveRoomViewV2 { + func syncScreenCaptureProtectionState() { + guard shouldEnforceScreenCaptureProtection else { + isScreenCaptureProtected = false + releaseForcedCaptureMute() + return + } + + applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured) + } + func applyScreenCaptureProtection(isCaptured: Bool) { // 시스템 캡쳐 상태를 로컬 UI 상태와 동기화 isScreenCaptureProtected = isCaptured diff --git a/docs/20260328_방장캡쳐녹화허용.md b/docs/20260328_방장캡쳐녹화허용.md new file mode 100644 index 0000000..fbc64b5 --- /dev/null +++ b/docs/20260328_방장캡쳐녹화허용.md @@ -0,0 +1,38 @@ +# 20260328 방장 캡쳐/녹화 허용 + +## 작업 체크리스트 +- [x] `LiveRoomViewV2` 캡쳐/녹화 보호 적용 지점 재확인 +- [x] 현재 사용자 방장 여부 계산 프로퍼티 추가 +- [x] 방장일 때 `ScreenCaptureSecureContainer` 미적용 분기 추가 +- [x] 방장일 때 캡쳐 감지 오버레이/강제 음소거 로직 비활성화 +- [x] LSP/빌드/테스트 검증 수행 +- [x] 검증 결과 기록 + +## 수용 기준 (Acceptance Criteria) +- [x] 방장(`creatorId == currentUserId`)은 라이브룸 화면에서 스크린샷/화면 녹화가 가능하다. (코드 경로 기준) +- [x] 비방장(게스트/리스너/스피커)은 기존 캡쳐/녹화 보호가 유지된다. +- [x] 캡쳐 감지 시 비방장에게만 검정 오버레이/강제 음소거가 적용된다. +- [x] 변경 파일 LSP 진단 오류가 없다. +- [x] `SodaLive`, `SodaLive-dev` Debug build가 성공한다. + +## 검증 기록 +### 1차 검증 (2026-03-28) +- 무엇/왜/어떻게: + - 무엇: 방장만 캡쳐/녹화 보호를 우회하도록 조건 분기를 적용. + - 왜: 요청사항(방장 캡쳐·녹화 허용) 충족과 비방장 보호 유지. + - 어떻게: `ScreenCaptureSecureContainer`를 런타임에서 secure on/off 가능한 구조로 확장하고, 방장 여부에 따라 캡쳐 보호 동기화를 분기했다. +- 실행 명령: + - `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - `grep("\\*\\* BUILD SUCCEEDED \\*\\*", /Users/klaus/.local/share/opencode/tool-output/tool_d340d3dc1001ZldmuCAgiYN1ly)` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test` + - `grep("shouldEnforceScreenCaptureProtection|syncScreenCaptureProtectionState", LiveRoomViewV2.swift)` +- 결과: + - `lsp_diagnostics`: `No diagnostics found` + - 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **` + - 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.` + - 수동 QA(코드 경로): + - 방장/비방장 분기 확인: `ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection)` + - 방장 동기화 확인: `syncScreenCaptureProtectionState()`에서 방장인 경우 `isScreenCaptureProtected = false` + `releaseForcedCaptureMute()` + - 비방장 보호 유지 확인: 비방장인 경우 `applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)` + - 제한사항: 현재 CLI/헤드리스 환경에서 실제 기기 스크린샷/화면녹화 버튼 조작 E2E는 불가하여, 실기기 최종 확인이 추가로 필요.