From 11c9935f7526807c76928e6fe34c456aaa5ced5d Mon Sep 17 00:00:00 2001 From: Son SeungJae Date: Sat, 28 Feb 2026 21:13:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20QRCode=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/DeepLink/DeepLinkType.swift | 25 +- .../Repository/AuthRepositoryImpl.swift | 4 + .../Sources/ServiceProtocol/AuthService.swift | 1 + .../TDDomain/Sources/DomainAssembly.swift | 9 +- .../RepositoryProtocol/AuthRepository.swift | 1 + .../Auth/AuthorizeWebSessionUseCase.swift | 17 ++ .../Sources/Network/API/AuthAPI.swift | 19 +- .../Sources/Service/AuthServiceImpl.swift | 5 + .../Sources/AppFlow/AppCoordinator.swift | 4 +- .../QRWebLogin/QRWebLoginPopupView.swift | 98 ++++++++ .../QRWebLoginPopupViewController.swift | 86 +++++++ .../MainFlow/MainTabBarCoordinator.swift | 14 ++ .../MainFlow/MyPage/MyPageCoordinator.swift | 20 ++ .../MyPageView/MyPageViewController.swift | 7 +- .../QRScanner/QRScannerViewController.swift | 215 ++++++++++++++++++ Projects/toduck/Sources/SceneDelegate.swift | 13 ++ 16 files changed, 522 insertions(+), 16 deletions(-) create mode 100644 Projects/TDDomain/Sources/UseCase/Auth/AuthorizeWebSessionUseCase.swift create mode 100644 Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupView.swift create mode 100644 Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupViewController.swift create mode 100644 Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/QRScanner/QRScannerViewController.swift diff --git a/Projects/TDCore/Sources/DeepLink/DeepLinkType.swift b/Projects/TDCore/Sources/DeepLink/DeepLinkType.swift index ae57b3261..c16b3e326 100644 --- a/Projects/TDCore/Sources/DeepLink/DeepLinkType.swift +++ b/Projects/TDCore/Sources/DeepLink/DeepLinkType.swift @@ -8,41 +8,46 @@ public enum DeepLinkType { case diary case home case notification - + case qrWebLogin(sessionToken: String) + public init?(url: URL) { guard url.scheme == "toduck" else { return nil } - + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryItems = components?.queryItems func queryValue(for name: String) -> String? { return queryItems?.first(where: { $0.name == name })?.value } - + switch url.host { case "profile": guard let userId = queryValue(for: "userId") else { return nil } self = .profile(userId: userId) - + case "post": guard let postId = queryValue(for: "postId") else { return nil } let commentId = queryValue(for: "commentId") self = .post(postId: postId, commentId: commentId) - + case "createPost": self = .createPost - + case "todo": self = .todo - + case "diary": self = .diary - + case "home": self = .home - + case "notification": self = .notification - + + case "qrWebLogin": + guard let sessionToken = queryValue(for: "sessionToken") else { return nil } + self = .qrWebLogin(sessionToken: sessionToken) + default: return nil } diff --git a/Projects/TDData/Sources/Repository/AuthRepositoryImpl.swift b/Projects/TDData/Sources/Repository/AuthRepositoryImpl.swift index 7a6dcc090..247b1c3a2 100644 --- a/Projects/TDData/Sources/Repository/AuthRepositoryImpl.swift +++ b/Projects/TDData/Sources/Repository/AuthRepositoryImpl.swift @@ -72,6 +72,10 @@ public struct AuthRepositoryImpl: AuthRepository { let loginUserResponseDTO = try await service.refreshToken() try await saveToken(response: loginUserResponseDTO) } + + public func authorizeWebSession(sessionToken: String) async throws { + try await service.authorizeWebSession(sessionToken: sessionToken) + } private func saveToken(response: LoginUserResponseDTO) async throws { let accessToken = response.accessToken diff --git a/Projects/TDData/Sources/ServiceProtocol/AuthService.swift b/Projects/TDData/Sources/ServiceProtocol/AuthService.swift index 1f5a3e48a..22bcd696f 100644 --- a/Projects/TDData/Sources/ServiceProtocol/AuthService.swift +++ b/Projects/TDData/Sources/ServiceProtocol/AuthService.swift @@ -10,4 +10,5 @@ public protocol AuthService { func requestKakaoLogin() async throws -> String func requestLogin(loginId: String, password: String) async throws -> LoginUserResponseDTO func refreshToken() async throws -> LoginUserResponseDTO + func authorizeWebSession(sessionToken: String) async throws } diff --git a/Projects/TDDomain/Sources/DomainAssembly.swift b/Projects/TDDomain/Sources/DomainAssembly.swift index 518858b38..54de5913f 100644 --- a/Projects/TDDomain/Sources/DomainAssembly.swift +++ b/Projects/TDDomain/Sources/DomainAssembly.swift @@ -54,7 +54,14 @@ public struct DomainAssembly: Assembly { } return RegisterUserUseCaseImpl(repository: repository) } - + + container.register(AuthorizeWebSessionUseCase.self) { resolver in + guard let repository = resolver.resolve(AuthRepository.self) else { + fatalError("컨테이너에 AuthRepository가 등록되어 있지 않습니다.") + } + return AuthorizeWebSessionUseCaseImpl(repository: repository) + } + // MARK: - User Auth UseCases container.register(FindUserIdUseCase.self) { resolve in diff --git a/Projects/TDDomain/Sources/RepositoryProtocol/AuthRepository.swift b/Projects/TDDomain/Sources/RepositoryProtocol/AuthRepository.swift index 45e99769c..08fa49474 100644 --- a/Projects/TDDomain/Sources/RepositoryProtocol/AuthRepository.swift +++ b/Projects/TDDomain/Sources/RepositoryProtocol/AuthRepository.swift @@ -10,4 +10,5 @@ public protocol AuthRepository { func requestAppleLogin(oauthId: String, idToken: String) async throws func requestLogin(loginId: String, password: String) async throws func refreshToken() async throws + func authorizeWebSession(sessionToken: String) async throws } diff --git a/Projects/TDDomain/Sources/UseCase/Auth/AuthorizeWebSessionUseCase.swift b/Projects/TDDomain/Sources/UseCase/Auth/AuthorizeWebSessionUseCase.swift new file mode 100644 index 000000000..77a2de97d --- /dev/null +++ b/Projects/TDDomain/Sources/UseCase/Auth/AuthorizeWebSessionUseCase.swift @@ -0,0 +1,17 @@ +import TDCore + +public protocol AuthorizeWebSessionUseCase { + func execute(sessionToken: String) async throws +} + +public struct AuthorizeWebSessionUseCaseImpl: AuthorizeWebSessionUseCase { + private let repository: AuthRepository + + public init(repository: AuthRepository) { + self.repository = repository + } + + public func execute(sessionToken: String) async throws { + try await repository.authorizeWebSession(sessionToken: sessionToken) + } +} diff --git a/Projects/TDNetwork/Sources/Network/API/AuthAPI.swift b/Projects/TDNetwork/Sources/Network/API/AuthAPI.swift index e7c866bb0..7ba130be1 100644 --- a/Projects/TDNetwork/Sources/Network/API/AuthAPI.swift +++ b/Projects/TDNetwork/Sources/Network/API/AuthAPI.swift @@ -1,4 +1,5 @@ import Foundation +import TDCore public enum AuthAPI { case requestPhoneVerification(phoneNumber: String) // 휴대폰 본인 인증 요청 @@ -10,6 +11,7 @@ public enum AuthAPI { case refreshToken(refreshToken: String) // 리프래시 토큰 발급 case saveFCMToken(userId: Int, fcmToken: String) // FCM 토큰 저장 case deleteUser(userId: Int) // 유저 삭제 + case authorizeWebSession(sessionToken: String) // QR 코드 웹 로그인 인증 } extension AuthAPI: MFTarget { @@ -37,6 +39,8 @@ extension AuthAPI: MFTarget { return "/users/\(userId)/fcm-token" case .deleteUser(let userId): return "/users/\(userId)" + case .authorizeWebSession: + return "/v1/auth/web/authorize" } } @@ -50,7 +54,8 @@ extension AuthAPI: MFTarget { case .registerUser, .login, .loginOauth, - .saveFCMToken: + .saveFCMToken, + .authorizeWebSession: return .post case .deleteUser: return .delete @@ -73,7 +78,8 @@ extension AuthAPI: MFTarget { case .registerUser, .login, .saveFCMToken, - .refreshToken: + .refreshToken, + .authorizeWebSession: return nil case .deleteUser(let userId): // TODO: - 나중 결정? @@ -110,6 +116,10 @@ extension AuthAPI: MFTarget { parameters: ["fcmToken": fcmToken] ) + case .authorizeWebSession(let sessionToken): + return .requestParameters( + parameters: ["sessionToken": sessionToken] + ) case .checkDuplicateUserID, .requestPhoneVerification, .checkPhoneVerification, @@ -132,6 +142,11 @@ extension AuthAPI: MFTarget { let cookieHeaderValue = "refreshToken=\(refreshToken)" let headers: MFHeaders = [.cookie(cookieHeaderValue), .accept("application/json")] return headers + case .authorizeWebSession: + return [ + .authorization(bearerToken: TDTokenManager.shared.accessToken ?? ""), + .contentType("application/json") + ] case .requestPhoneVerification, .saveFCMToken, .deleteUser: diff --git a/Projects/TDNetwork/Sources/Service/AuthServiceImpl.swift b/Projects/TDNetwork/Sources/Service/AuthServiceImpl.swift index f5d0e6ae4..29225bfe9 100644 --- a/Projects/TDNetwork/Sources/Service/AuthServiceImpl.swift +++ b/Projects/TDNetwork/Sources/Service/AuthServiceImpl.swift @@ -106,6 +106,11 @@ public struct AuthServiceImpl: AuthService { } } + public func authorizeWebSession(sessionToken: String) async throws { + let target = AuthAPI.authorizeWebSession(sessionToken: sessionToken) + try await provider.requestDecodable(of: EmptyResponse.self, target) + } + private func mapToLoginUserResponseDTO(from response: MFResponse) throws -> LoginUserResponseDTO { if let refreshToken = response.extractRefreshToken(), let refreshTokenExpiredAt = response.extractRefreshTokenExpiry() { diff --git a/Projects/TDPresentation/Sources/AppFlow/AppCoordinator.swift b/Projects/TDPresentation/Sources/AppFlow/AppCoordinator.swift index 171bdead8..afed0b853 100644 --- a/Projects/TDPresentation/Sources/AppFlow/AppCoordinator.swift +++ b/Projects/TDPresentation/Sources/AppFlow/AppCoordinator.swift @@ -169,7 +169,7 @@ public final class AppCoordinator: Coordinator { pendingDeepLink = link return } - + guard let tabBarCoordinator = childCoordinators.first(where: { $0 is MainTabBarCoordinator }) as? MainTabBarCoordinator else { pendingDeepLink = link startTabBarFlow { [weak self] in @@ -177,7 +177,7 @@ public final class AppCoordinator: Coordinator { } return } - + tabBarCoordinator.handleDeepLink(link) } diff --git a/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupView.swift b/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupView.swift new file mode 100644 index 000000000..178a43dfc --- /dev/null +++ b/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupView.swift @@ -0,0 +1,98 @@ +import SnapKit +import TDDesign +import UIKit + +final class QRWebLoginPopupView: BaseView { + private let stackView = UIStackView().then { + $0.axis = .vertical + $0.spacing = 0 + $0.alignment = .center + } + + private let buttonStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 4 + $0.alignment = .fill + $0.distribution = .fillEqually + } + + let popupImageView = UIImageView().then { + $0.image = TDImage.loginToduck + $0.contentMode = .scaleAspectFit + } + + let titleLabel = TDLabel( + toduckFont: .boldHeader4, + toduckColor: TDColor.Neutral.neutral800 + ) + + let descriptionLabel = TDLabel( + toduckFont: .mediumHeader5, + toduckColor: TDColor.Neutral.neutral600 + ) + + private(set) var loginButton = TDBaseButton( + title: "로그인", + backgroundColor: TDColor.Primary.primary500, + foregroundColor: TDColor.baseWhite, + radius: 12, + font: TDFont.boldBody1.font + ) + + private(set) var cancelButton = TDBaseButton( + title: "취소", + backgroundColor: TDColor.baseWhite, + foregroundColor: TDColor.Neutral.neutral700, + radius: 12, + font: TDFont.boldBody1.font + ).then { + $0.layer.borderWidth = 1 + $0.layer.borderColor = TDColor.Neutral.neutral300.cgColor + } + + override func addview() { + addSubview(stackView) + stackView.addArrangedSubview(popupImageView) + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(descriptionLabel) + stackView.addArrangedSubview(buttonStackView) + buttonStackView.addArrangedSubview(cancelButton) + buttonStackView.addArrangedSubview(loginButton) + } + + override func layout() { + stackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 28, left: 20, bottom: 28, right: 20)) + } + + popupImageView.snp.makeConstraints { + $0.width.equalTo(200) + $0.height.equalTo(200) + } + + stackView.setCustomSpacing(16, after: popupImageView) + stackView.setCustomSpacing(8, after: titleLabel) + stackView.setCustomSpacing(24, after: descriptionLabel) + + buttonStackView.snp.makeConstraints { + $0.height.equalTo(52) + $0.width.equalToSuperview() + } + + cancelButton.snp.makeConstraints { + $0.height.equalTo(52) + } + + loginButton.snp.makeConstraints { + $0.height.equalTo(52) + } + } + + override func configure() { + layer.cornerRadius = 28 + backgroundColor = TDColor.baseWhite + + titleLabel.setText("웹 로그인 요청이 들어왔어요") + descriptionLabel.setText("toduck 웹 페이지에 로그인할까요?") + } +} diff --git a/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupViewController.swift b/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupViewController.swift new file mode 100644 index 000000000..5e4dd2c8d --- /dev/null +++ b/Projects/TDPresentation/Sources/AppFlow/AuthFlow/QRWebLogin/QRWebLoginPopupViewController.swift @@ -0,0 +1,86 @@ +import SnapKit +import TDDesign +import TDDomain +import Then +import UIKit + +final class QRWebLoginPopupViewController: TDPopupViewController { + private let sessionToken: String + private let authorizeWebSessionUseCase: AuthorizeWebSessionUseCase + var onLoginSuccess: (() -> Void)? + + init(sessionToken: String, authorizeWebSessionUseCase: AuthorizeWebSessionUseCase) { + self.sessionToken = sessionToken + self.authorizeWebSessionUseCase = authorizeWebSessionUseCase + super.init() + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func configure() { + super.configure() + isBackgroundGestureEnabled = false + + popupContentView.loginButton.addAction(UIAction { [weak self] _ in + self?.handleLogin() + }, for: .touchUpInside) + + popupContentView.cancelButton.addAction(UIAction { [weak self] _ in + self?.dismissPopup() + }, for: .touchUpInside) + } + + private func handleLogin() { + popupContentView.loginButton.isEnabled = false + + Task { [weak self] in + guard let self else { return } + do { + try await authorizeWebSessionUseCase.execute(sessionToken: sessionToken) + await MainActor.run { + self.dismiss(animated: true) { + self.onLoginSuccess?() + self.showLoginSuccessToast() + } + } + } catch { + await MainActor.run { + self.popupContentView.loginButton.isEnabled = true + self.showErrorAlert(errorMessage: "웹 로그인에 실패했습니다.") + } + } + } + } + + private func showLoginSuccessToast() { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { return } + + let toastLabel = UILabel().then { + $0.text = "웹 로그인이 완료되었습니다." + $0.font = TDFont.boldBody2.font + $0.textColor = TDColor.baseWhite + $0.backgroundColor = TDColor.Neutral.neutral700 + $0.textAlignment = .center + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + + window.addSubview(toastLabel) + toastLabel.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.bottom.equalTo(window.safeAreaLayoutGuide).inset(32) + $0.height.equalTo(44) + $0.leading.trailing.equalToSuperview().inset(40) + } + + UIView.animate(withDuration: 0.3, delay: 1.5, options: .curveEaseOut) { + toastLabel.alpha = 0 + } completion: { _ in + toastLabel.removeFromSuperview() + } + } +} diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift index 3553eb437..933791a3b 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MainTabBarCoordinator.swift @@ -104,8 +104,22 @@ final class MainTabBarCoordinator: Coordinator, DeepLinkRoutable { case .profile, .post, .createPost: handleSocialDeepLink(link) + + case .qrWebLogin(let sessionToken): + showQRWebLoginPopup(sessionToken: sessionToken) } } + + private func showQRWebLoginPopup(sessionToken: String) { + let authorizeWebSessionUseCase = injector.resolve(AuthorizeWebSessionUseCase.self) + let popupVC = QRWebLoginPopupViewController( + sessionToken: sessionToken, + authorizeWebSessionUseCase: authorizeWebSessionUseCase + ) + popupVC.modalPresentationStyle = .overFullScreen + popupVC.modalTransitionStyle = .crossDissolve + tabBarController.present(popupVC, animated: true) + } private func handleSocialDeepLink(_ link: DeepLinkType) { selectTab(.social) diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageCoordinator.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageCoordinator.swift index fabf39875..8eded0c07 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageCoordinator.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageCoordinator.swift @@ -162,4 +162,24 @@ extension MyPageCoordinator: NavigationDelegate { childCoordinators.append(privacyPolicyCoordinator) privacyPolicyCoordinator.start() } + + func didTapQRCodeScan() { + let scannerVC = QRScannerViewController() + scannerVC.modalPresentationStyle = .fullScreen + scannerVC.onSessionTokenDetected = { [weak self, weak scannerVC] sessionToken in + guard let self, let scannerVC else { return } + let authorizeWebSessionUseCase = injector.resolve(AuthorizeWebSessionUseCase.self) + let popupVC = QRWebLoginPopupViewController( + sessionToken: sessionToken, + authorizeWebSessionUseCase: authorizeWebSessionUseCase + ) + popupVC.modalPresentationStyle = .overFullScreen + popupVC.modalTransitionStyle = .crossDissolve + popupVC.onLoginSuccess = { + scannerVC.dismiss(animated: true) + } + scannerVC.present(popupVC, animated: true) + } + navigationController.present(scannerVC, animated: true) + } } diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/MyPageViewController.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/MyPageViewController.swift index 541e696b9..567009387 100644 --- a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/MyPageViewController.swift +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/MyPageViewController.swift @@ -16,7 +16,8 @@ final class MyPageViewController: BaseViewController { .notificationSettings, .postManagement, .myComments, - .blockManagement + .blockManagement, + .qrCodeScan ] ), MenuSection( @@ -94,6 +95,8 @@ final class MyPageViewController: BaseViewController { coordinator?.didTapTermsOfUse() case .privacyPolicy: coordinator?.didTapPrivacyPolicy() + case .qrCodeScan: + coordinator?.didTapQRCodeScan() } } } @@ -212,6 +215,7 @@ enum MenuItem { case postManagement case myComments case blockManagement + case qrCodeScan case termsOfUse case privacyPolicy @@ -221,6 +225,7 @@ enum MenuItem { case .postManagement: "작성 글 관리" case .myComments: "나의 댓글" case .blockManagement: "차단 관리" + case .qrCodeScan: "QR 코드 스캔" case .termsOfUse: "이용 약관" case .privacyPolicy: "개인정보 처리방침" } diff --git a/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/QRScanner/QRScannerViewController.swift b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/QRScanner/QRScannerViewController.swift new file mode 100644 index 000000000..ad994ed01 --- /dev/null +++ b/Projects/TDPresentation/Sources/AppFlow/MainFlow/MyPage/MyPageView/QRScanner/QRScannerViewController.swift @@ -0,0 +1,215 @@ +import AVFoundation +import SnapKit +import TDDesign +import UIKit + +final class QRScannerViewController: UIViewController { + // MARK: - Callback + + var onSessionTokenDetected: ((String) -> Void)? + + // MARK: - AVFoundation + + private let captureSession = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var hasDetected = false + + // MARK: - UI + + private let dimView = UIView().then { + $0.backgroundColor = UIColor.black.withAlphaComponent(0.6) + } + + private let scanFrameView = UIView().then { + $0.layer.borderColor = TDColor.Primary.primary500.cgColor + $0.layer.borderWidth = 3 + $0.layer.cornerRadius = 16 + $0.backgroundColor = .clear + } + + private let guideLabel = TDLabel( + toduckFont: .mediumBody2, + toduckColor: TDColor.baseWhite + ).then { + $0.setText("웹 페이지의 QR 코드를 네모 안에 맞춰주세요") + $0.textAlignment = .center + } + + private let closeButton = UIButton(type: .system).then { + $0.setImage(UIImage(systemName: "xmark"), for: .normal) + $0.tintColor = TDColor.baseWhite + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + setupCamera() + setupUI() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if !captureSession.isRunning { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.captureSession.startRunning() + } + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if captureSession.isRunning { + captureSession.stopRunning() + } + } + + // MARK: - Camera Setup + + private func setupCamera() { + guard let device = AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: device) else { + showCameraUnavailableAlert() + return + } + + let metadataOutput = AVCaptureMetadataOutput() + captureSession.beginConfiguration() + + if captureSession.canAddInput(input) { + captureSession.addInput(input) + } + if captureSession.canAddOutput(metadataOutput) { + captureSession.addOutput(metadataOutput) + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [.qr] + } + + captureSession.commitConfiguration() + + let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.videoGravity = .resizeAspectFill + previewLayer.frame = view.bounds + view.layer.insertSublayer(previewLayer, at: 0) + self.previewLayer = previewLayer + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.captureSession.startRunning() + } + } + + // MARK: - UI Setup + + private func setupUI() { + view.addSubview(dimView) + view.addSubview(scanFrameView) + view.addSubview(guideLabel) + view.addSubview(closeButton) + + dimView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + let scanSize: CGFloat = 260 + scanFrameView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.height.equalTo(scanSize) + } + + guideLabel.snp.makeConstraints { + $0.top.equalTo(scanFrameView.snp.bottom).offset(24) + $0.leading.trailing.equalToSuperview().inset(24) + } + + closeButton.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(16) + $0.trailing.equalToSuperview().inset(20) + $0.width.height.equalTo(44) + } + + // scanFrame 영역은 투명하게 뚫어주기 + view.layoutIfNeeded() + applyDimMask() + + closeButton.addAction(UIAction { [weak self] _ in + self?.dismiss(animated: true) + }, for: .touchUpInside) + } + + private func applyDimMask() { + let path = UIBezierPath(rect: view.bounds) + let scanFrame = scanFrameView.frame + let scanPath = UIBezierPath(roundedRect: scanFrame, cornerRadius: 16) + path.append(scanPath) + path.usesEvenOddFillRule = true + + let maskLayer = CAShapeLayer() + maskLayer.path = path.cgPath + maskLayer.fillRule = .evenOdd + dimView.layer.mask = maskLayer + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + previewLayer?.frame = view.bounds + applyDimMask() + updateScanRectOfInterest() + } + + private func updateScanRectOfInterest() { + guard let previewLayer else { return } + let scanFrame = scanFrameView.frame + // AVFoundation 좌표계 변환 (정규화, 원점이 우하단) + let metadataOutput = captureSession.outputs.first as? AVCaptureMetadataOutput + metadataOutput?.rectOfInterest = previewLayer.metadataOutputRectConverted(fromLayerRect: scanFrame) + } + + // MARK: - Helpers + + private func showCameraUnavailableAlert() { + DispatchQueue.main.async { + let alert = UIAlertController( + title: "카메라를 사용할 수 없어요", + message: "설정에서 카메라 접근 권한을 허용해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default) { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) + alert.addAction(UIAlertAction(title: "닫기", style: .cancel) { [weak self] _ in + self?.dismiss(animated: true) + }) + self.present(alert, animated: true) + } + } +} + +// MARK: - AVCaptureMetadataOutputObjectsDelegate + +extension QRScannerViewController: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput( + _ output: AVCaptureMetadataOutput, + didOutput metadataObjects: [AVMetadataObject], + from connection: AVCaptureConnection + ) { + guard !hasDetected, + let metadata = metadataObjects.first as? AVMetadataMachineReadableCodeObject, + let rawString = metadata.stringValue, + let url = URL(string: rawString) else { return } + + // https://toduck.app/_ul/w/{sessionToken} 패턴 확인 + guard url.host == "toduck.app", + url.path.hasPrefix("/_ul/w/") else { return } + + let sessionToken = String(url.path.dropFirst("/_ul/w/".count)) + guard !sessionToken.isEmpty else { return } + + hasDetected = true + captureSession.stopRunning() + + onSessionTokenDetected?(sessionToken) + } +} diff --git a/Projects/toduck/Sources/SceneDelegate.swift b/Projects/toduck/Sources/SceneDelegate.swift index be7abd688..6355e5f4c 100644 --- a/Projects/toduck/Sources/SceneDelegate.swift +++ b/Projects/toduck/Sources/SceneDelegate.swift @@ -77,6 +77,19 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // /_ul/ 이후 경로 추출 let pathWithoutPrefix = String(url.path.dropFirst(5)) // "/_ul/" 제거 + // QR 웹 로그인 처리: /_ul/w/{sessionToken} + if pathWithoutPrefix.hasPrefix("w/") { + let sessionToken = String(pathWithoutPrefix.dropFirst(2)) + guard !sessionToken.isEmpty else { return } + var components = URLComponents() + components.scheme = "toduck" + components.host = "qrWebLogin" + components.queryItems = [URLQueryItem(name: "sessionToken", value: sessionToken)] + guard let customSchemeURL = components.url else { return } + handleDeepLink(url: customSchemeURL) + return + } + // toduck:// 스킴으로 변환 var components = URLComponents() components.scheme = "toduck"