import Foundation /// 워치 전용 경량 API 클라이언트. DS 공개 TLS(document.hyungi.net) 직접 도달 — 워치는 Tailscale 불가. /// access 토큰=메모리 / refresh 쿠키=HTTPCookieStorage(7일 영속) 라 1회 로그인 후 자동 유지. /// 계약은 백엔드 Pydantic 모델에서 추출(study_cards.py CardItem/RateBody) — 지어내지 않음. enum WatchAPI { static let baseString = "https://document.hyungi.net/api" } /// GET /study-cards/due 의 CardItem (워치가 쓰는 필드만). struct WCard: Decodable, Identifiable, Sendable { let id: Int let format: String let cue: String let fact: String let clozeText: String? let needsReview: Bool let reviewStage: Int? enum CodingKeys: String, CodingKey { case id, format, cue, fact case clozeText = "cloze_text" case needsReview = "needs_review" case reviewStage = "review_stage" } } /// GET /events/today 의 EventResponse (워치 할일이 쓰는 필드만). struct WEvent: Decodable, Identifiable, Sendable { let id: Int let title: String let status: String let dueAt: String? let completedAt: String? enum CodingKeys: String, CodingKey { case id, title, status case dueAt = "due_at" case completedAt = "completed_at" } var isDone: Bool { status == "completed" || completedAt != nil } } private struct WEventList: Decodable { let items: [WEvent] } /// GET /briefing/latest 의 토픽/국가관점 (워치 글랜스용 부분집합). struct WPerspective: Decodable, Identifiable, Sendable { let country: String let summary: String var id: String { country } } struct WTopic: Decodable, Identifiable, Sendable { let id: Int let topicLabel: String let headline: String let countryPerspectives: [WPerspective] enum CodingKeys: String, CodingKey { case id, headline case topicLabel = "topic_label" case countryPerspectives = "country_perspectives" } } struct WBriefing: Decodable, Sendable { let status: String let headlineOneliner: String? let topics: [WTopic] enum CodingKeys: String, CodingKey { case status, topics case headlineOneliner = "headline_oneliner" } } /// 이드 채팅 결과 — SSE 누적 답변 또는 unavailable(맥미니 대기/고장). struct ChatResult: Sendable { let answer: String let unavailable: Bool let reason: String? } private struct AccessTokenBody: Decodable { let accessToken: String enum CodingKeys: String, CodingKey { case accessToken = "access_token" } } enum WCError: Error, LocalizedError { case transport(String) case http(Int, String?) case decoding(String) var errorDescription: String? { switch self { case .transport(let m): return "네트워크 오류: \(m)" case .http(let s, let m): return m ?? "서버 오류 (\(s))" case .decoding(let m): return "응답 해석 실패: \(m)" } } var isUnauthorized: Bool { if case .http(401, _) = self { return true }; return false } } actor WatchClient { private let session: URLSession private var accessToken: String? init() { let cfg = URLSessionConfiguration.default cfg.httpCookieStorage = .shared cfg.httpShouldSetCookies = true cfg.waitsForConnectivity = true session = URLSession(configuration: cfg) } private func url(_ path: String) -> URL { URL(string: WatchAPI.baseString + "/" + path)! } private func send(_ req: URLRequest) async throws -> (Data, HTTPURLResponse) { do { let (d, r) = try await session.data(for: req) guard let h = r as? HTTPURLResponse else { throw WCError.transport("no HTTP response") } return (d, h) } catch let e as WCError { throw e } catch { throw WCError.transport("\(error.localizedDescription)") } } private static func decodeMessage(_ data: Data) -> String? { guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if let s = o["detail"] as? String { return s } if let d = o["detail"] as? [String: Any] { return d["message"] as? String } return nil } // MARK: auth func login(username: String, password: String, totp: String?) async throws { var req = URLRequest(url: url("auth/login")) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") var body: [String: Any] = ["username": username, "password": password] if let totp, !totp.isEmpty { body["totp_code"] = totp } req.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, http) = try await send(req) guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } accessToken = try decodeToken(data) } @discardableResult func refresh() async throws -> String { var req = URLRequest(url: url("auth/refresh")) req.httpMethod = "POST" let (data, http) = try await send(req) guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } let t = try decodeToken(data) accessToken = t return t } func logout() async { accessToken = nil var req = URLRequest(url: url("auth/logout")); req.httpMethod = "POST" _ = try? await send(req) } private func decodeToken(_ data: Data) throws -> String { do { return try JSONDecoder().decode(AccessTokenBody.self, from: data).accessToken } catch { throw WCError.decoding("token: \(error)") } } // MARK: authed request (401 → single refresh + retry) private func authed(_ path: String, method: String = "GET", json: [String: Any]? = nil) async throws -> Data { func make(_ token: String?) throws -> URLRequest { var r = URLRequest(url: url(path)) r.httpMethod = method if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let json { r.httpBody = try JSONSerialization.data(withJSONObject: json); r.setValue("application/json", forHTTPHeaderField: "Content-Type") } return r } let (data, http) = try await send(make(accessToken)) if http.statusCode == 401 { let newToken = try await refresh() let (d2, h2) = try await send(make(newToken)) guard (200..<300).contains(h2.statusCode) else { throw WCError.http(h2.statusCode, Self.decodeMessage(d2)) } return d2 } guard (200..<300).contains(http.statusCode) else { throw WCError.http(http.statusCode, Self.decodeMessage(data)) } return data } // MARK: study cards func dueCards() async throws -> [WCard] { let data = try await authed("study-cards/due") do { return try JSONDecoder().decode([WCard].self, from: data) } catch { throw WCError.decoding("due: \(error)") } } func rate(cardId: Int, outcome: String) async throws { _ = try await authed("study-cards/\(cardId)/rate", method: "POST", json: ["outcome": outcome]) } func flag(cardId: Int) async throws { _ = try await authed("study-cards/\(cardId)", method: "PATCH", json: ["needs_review": true]) } // MARK: events (할일) func events() async throws -> [WEvent] { let data = try await authed("events/today") do { return try JSONDecoder().decode(WEventList.self, from: data).items } catch { throw WCError.decoding("events: \(error)") } } func completeEvent(id: Int) async throws { _ = try await authed("events/\(id)/complete", method: "POST") } // MARK: briefing (모닝 브리핑) func briefing() async throws -> WBriefing { let data = try await authed("briefing/latest") do { return try JSONDecoder().decode(WBriefing.self, from: data) } catch { throw WCError.decoding("briefing: \(error)") } } // MARK: eid chat (SSE 누적 — 맥미니 26B via DS 프록시) func chat(_ text: String) async throws -> ChatResult { let payload: [String: Any] = ["mode": "daily", "messages": [["role": "user", "content": text]]] func make(_ token: String?) throws -> URLRequest { var r = URLRequest(url: url("eid/chat")) r.httpMethod = "POST" r.timeoutInterval = 120 if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } r.setValue("application/json", forHTTPHeaderField: "Content-Type") r.setValue("text/event-stream", forHTTPHeaderField: "Accept") r.httpBody = try JSONSerialization.data(withJSONObject: payload) return r } var (stream, resp) = try await session.bytes(for: make(accessToken)) if (resp as? HTTPURLResponse)?.statusCode == 401 { let t = try await refresh() (stream, resp) = try await session.bytes(for: make(t)) } guard let http = resp as? HTTPURLResponse else { throw WCError.transport("no HTTP response") } let ctype = http.value(forHTTPHeaderField: "Content-Type") ?? "" if ctype.contains("text/event-stream") { var answer = "" for try await line in stream.lines { guard line.hasPrefix("data:") else { continue } let body = line.dropFirst(5).trimmingCharacters(in: .whitespaces) if body == "[DONE]" || body.isEmpty { continue } if let d = body.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any], let choices = obj["choices"] as? [[String: Any]], let delta = choices.first?["delta"] as? [String: Any], let content = delta["content"] as? String { answer += content } } return ChatResult(answer: answer, unavailable: answer.isEmpty, reason: answer.isEmpty ? "빈 응답" : nil) } // 비-스트림 = unavailable JSONResponse (맥미니 대기/고장) — 사유 추출. var raw = Data() for try await b in stream { raw.append(b) } return ChatResult(answer: "", unavailable: true, reason: Self.decodeMessage(raw) ?? "이드 연결 불가") } }