e717de69ca
- standalone watchOS(WKApplication + WKWatchOnly), 다크 OLED, xcodegen 단일 타깃 - 4기능 = 이드(AI채팅)·공부(암기카드)·할일·브리핑 - 라이브: 공부 /study-cards(due·rate·flag) · 할일 /events(today·complete) · 브리핑 /briefing/latest · 이드 /eid/chat(SSE 누적, unavailable 처리) - 1회 로그인(access 메모리 + refresh 쿠키 7일 영속) + 401 자동 refresh+재시도 - 햅틱 피드백 + 정직한 로딩/빈/오류 상태 + DS 초록 아이콘(원형 마스킹) - 맥·아이폰은 웹 래퍼로(2026-06-15 결정), 순수 네이티브는 워치 전용 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
263 lines
10 KiB
Swift
263 lines
10 KiB
Swift
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) ?? "이드 연결 불가")
|
|
}
|
|
}
|