diff --git a/today-s-sound/App/TodaySSoundApp.swift b/today-s-sound/App/TodaySSoundApp.swift index 127aaf5..bb5020b 100644 --- a/today-s-sound/App/TodaySSoundApp.swift +++ b/today-s-sound/App/TodaySSoundApp.swift @@ -9,9 +9,18 @@ import SwiftUI @main struct TodaySSoundApp: App { - var body: some Scene { - WindowGroup { - MainView() + @StateObject private var session = SessionStore() + + var body: some Scene { + WindowGroup { + Group { + if session.isRegistered { + MainView() + } else { + OnBoardingView() + } + } + .environmentObject(session) + } } - } } diff --git a/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift new file mode 100644 index 0000000..6bcd6b6 --- /dev/null +++ b/today-s-sound/Presentation/Features/OnBoarding/OnBoardingView.swift @@ -0,0 +1,57 @@ +// +// OnBoardingView.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import SwiftUI + +struct OnBoardingView: View { + @EnvironmentObject var session: SessionStore + @State private var isLoading = false + + var body: some View { + VStack(spacing: 20) { + Text("환영합니다 👋") + .font(.largeTitle).bold() + Text("이 기기를 익명 사용자로 등록하고 서비스를 시작합니다.") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + + if isLoading { + ProgressView("등록 중…") + .padding(.top, 8) + } else { + Button { + Task { + isLoading = true + defer { isLoading = false } + await session.registerIfNeeded() + } + } label: { + Text("시작하기") + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(.top, 12) + } + + if let err = session.lastError { + Text(err) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.top, 8) + } + + // 디버그: 생성된 deviceSecret 미리보기(실서비스에서는 숨기기) + // if let s = Keychain.getString(for: KeychainKey.deviceSecret) { + // Text("secret: \(s)").font(.footnote).foregroundStyle(.secondary) + // } + } + .padding(24) + } +} diff --git a/today-s-sound/Services/AppState/SessionStore.swift b/today-s-sound/Services/AppState/SessionStore.swift new file mode 100644 index 0000000..7ffe585 --- /dev/null +++ b/today-s-sound/Services/AppState/SessionStore.swift @@ -0,0 +1,66 @@ +// +// SessionStore.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import SwiftUI + +@MainActor +final class SessionStore: ObservableObject { + @Published private(set) var userId: String? + @Published private(set) var isRegistered: Bool = false + @Published var lastError: String? + + private let api = APIClient() + + init() { + // 앱 시작 시, 키체인에 저장되어 있으면 로드 + if let savedId = Keychain.getString(for: KeychainKey.userId) { + self.userId = savedId + self.isRegistered = true + } else { + self.isRegistered = false + } + } + + /// 처음 실행 시 한 번 호출: deviceSecret 생성/보관 → 서버 등록 + func registerIfNeeded() async { + guard !isRegistered else { return } + + // 1) deviceSecret을 키체인에서 찾고, 없으면 생성하여 저장 + let secret: String = { + if let s = Keychain.getString(for: KeychainKey.deviceSecret) { return s } + let gen = DeviceSecret.generate() + Keychain.setString(gen, for: KeychainKey.deviceSecret) + return gen + }() + + do { + // 2) 서버 호출 + let body = RegisterAnonymousBody(deviceSecret: secret) + typealias Resp = SuccessEnvelope + let envelope: Resp = try await api.postJSON(path: "/api/users/anonymous", body: body) + + // 3) user_id 보관 + Keychain.setString(envelope.result.userId, for: KeychainKey.userId) + self.userId = envelope.result.userId + self.isRegistered = true + + // (옵션) 서버가 api_key 같은 걸 준다면 저장 + // if let key = envelope.result.api_key { Keychain.setString(key, for: KeychainKey.apiKey) } + + } catch let APIError.http(_, data) { + // 스웨거 에러 포맷 시도 디코딩 + if let data, let err = try? JSONDecoder().decode(ErrorEnvelope.self, from: data) { + lastError = "[\(err.code)] \(err.message)" + } else { + lastError = "알 수 없는 서버 오류" + } + } catch { + lastError = error.localizedDescription + } + } +} diff --git a/today-s-sound/Services/Config/Enviroment.swift b/today-s-sound/Services/Config/Enviroment.swift new file mode 100644 index 0000000..2bd4160 --- /dev/null +++ b/today-s-sound/Services/Config/Enviroment.swift @@ -0,0 +1,13 @@ +// +// Enviroment.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation + +enum AppConfig { + // 배포/개발 분기 필요하면 Scheme/xcconfig로 주입해도 됨 + static let baseURL = URL(string: "http://localhost:8080")! // 예: https://api.example.com +} diff --git a/today-s-sound/Services/Network/APIClient.swift b/today-s-sound/Services/Network/APIClient.swift new file mode 100644 index 0000000..ae8e41f --- /dev/null +++ b/today-s-sound/Services/Network/APIClient.swift @@ -0,0 +1,62 @@ +// +// APIClient.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation + +enum APIError: Error, LocalizedError { + case invalidURL + case http(status: Int, data: Data?) + case decoding(Error) + case underlying(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: return "잘못된 URL" + case .http(let s, _): return "서버 오류(\(s))" + case .decoding(let e): return "디코딩 오류: \(e.localizedDescription)" + case .underlying(let e): return e.localizedDescription + } + } +} + +final class APIClient { + private let baseURL: URL + private let session: URLSession + + init(baseURL: URL = AppConfig.baseURL) { + self.baseURL = baseURL + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = HTTPCookieStorage.shared + cfg.httpShouldSetCookies = true + cfg.httpAdditionalHeaders = ["Accept": "application/json", + "Content-Type": "application/json"] + self.session = URLSession(configuration: cfg) + } + + func postJSON( + path: String, + body: Request + ) async throws -> Response { + guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.httpBody = try JSONEncoder().encode(body) + + let (data, resp) = try await session.data(for: req) // 요청 보내는 코드 + print("서버 raw 응답:", String(data: data, encoding: .utf8) ?? "") + guard let http = resp as? HTTPURLResponse else { throw APIError.underlying(URLError(.badServerResponse)) } + guard (200..<300).contains(http.statusCode) else { + throw APIError.http(status: http.statusCode, data: data) + } + do { + return try JSONDecoder().decode(Response.self, from: data) + } catch { + // 성공 스펙이 래핑되어 있지 않거나 빈 바디일 수 있으니 필요시 분기 + throw APIError.decoding(error) + } + } +} diff --git a/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift new file mode 100644 index 0000000..bae95da --- /dev/null +++ b/today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift @@ -0,0 +1,48 @@ +// +// RegisterAnonymous.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import CryptoKit + +// 1) Request +struct RegisterAnonymousBody: Encodable { + let deviceSecret: String +} + +// 2) 성공 Response 래퍼 +struct SuccessEnvelope: Decodable { + let errorCode: String? + let message: String + let result: AnonymousResult +} + +struct AnonymousResult: Decodable { + let userId: String + // 서버가 추가로 키 같은 걸 준다면 여기에 옵셔널로: + // let api_key: String? +} + +// 3) 에러 Response +struct ErrorEnvelope: Decodable, Error { + let status: Int + let code: String + let message: String +} + +// 4) deviceSecret 생성 유틸 (32바이트 랜덤 → base64URL) +enum DeviceSecret { + static func generate() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + // URL-safe base64 + let data = Data(bytes) + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/today-s-sound/Services/Security/Keychain.swift b/today-s-sound/Services/Security/Keychain.swift new file mode 100644 index 0000000..9be506a --- /dev/null +++ b/today-s-sound/Services/Security/Keychain.swift @@ -0,0 +1,64 @@ +// +// Keychain.swift +// today-s-sound +// +// Created by 하승연 on 10/30/25. +// + +import Foundation +import Security + +enum KeychainKey { + static let deviceSecret = "device_secret" + static let userId = "user_id" + static let apiKey = "api_key" // 서버가 추가 키를 준다면 여기에 저장 (옵셔널) +} + +enum Keychain { + @discardableResult + static func set(_ value: Data, for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: value, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock // 앱 재부팅 후에도 접근 + ] + SecItemDelete(query as CFDictionary) // 기존 값 제거 + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + static func get(for key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess else { return nil } + return (item as? Data) + } + + @discardableResult + static func delete(for key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } + + // 문자열 편의 + @discardableResult + static func setString(_ value: String, for key: String) -> Bool { + set(Data(value.utf8), for: key) + } + + static func getString(for key: String) -> String? { + guard let data = get(for: key) else { return nil } + return String(data: data, encoding: .utf8) + } +} diff --git a/today-s-sound/Core/SpeechService.swift b/today-s-sound/Services/SpeechService.swift similarity index 100% rename from today-s-sound/Core/SpeechService.swift rename to today-s-sound/Services/SpeechService.swift