Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions today-s-sound/App/TodaySSoundApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
66 changes: 66 additions & 0 deletions today-s-sound/Services/AppState/SessionStore.swift
Original file line number Diff line number Diff line change
@@ -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<AnonymousResult>
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
}
}
}
13 changes: 13 additions & 0 deletions today-s-sound/Services/Config/Enviroment.swift
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions today-s-sound/Services/Network/APIClient.swift
Original file line number Diff line number Diff line change
@@ -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<Request: Encodable, Response: Decodable>(
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) ?? "<binary>")
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)
}
}
}
48 changes: 48 additions & 0 deletions today-s-sound/Services/Network/Endpoints/RegisterAnonymous.swift
Original file line number Diff line number Diff line change
@@ -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<AnonymousResult: Decodable>: 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: "")
}
}
64 changes: 64 additions & 0 deletions today-s-sound/Services/Security/Keychain.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading