import SwiftUI import Observation import DSKit import AIFabric /// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are /// main-isolated; the DSClient returns Sendable models; AIService is an actor. @MainActor @Observable public final class AppModel { public enum Section: String, CaseIterable, Identifiable, Hashable { case dashboard, documents, search, ask, memos, digest public var id: String { rawValue } public var title: String { switch self { case .dashboard: return "대시보드" case .documents: return "문서" case .search: return "검색" case .ask: return "질문" case .memos: return "메모" case .digest: return "뉴스" } } } /// 인증 단계: 시작 시 refresh 쿠키로 무로그인 복귀 시도(checking) → 실패 시 로그인 화면(loggedOut) /// → 성공 시 셸(ready). Fixture 클라이언트는 refresh 가 fixture 토큰을 돌려줘 곧장 ready. public enum AuthPhase: Equatable { case checking, loggedOut, ready } public var section: Section = .dashboard public var selectedDocumentID: Int? public var selectedMemoID: Int? public var tree: [DomainTreeNode] = [] public var stats: CategoryCounts? public var documentList: [DocumentResponse] = [] public var documentDetail: DocumentDetailResponse? public var searchQuery: String = "" public var searchResponse: SearchResponse? public var askQuery: String = "" public var askResult: AIResult? public var askMeta: DSKit.AskResponse? // qualified: AIFabric also defines an AskResponse public var memoList: [MemoResponse] = [] public var memoDetail: MemoResponse? public var digest: DigestResponse? public var errorText: String? public private(set) var authPhase: AuthPhase = .checking /// 로그인 화면에 노출되는 실패 사유 (재시도 시 소거). public var loginError: String? /// bootstrap single-shot 가드 (취소 시에만 되돌림). private var didBootstrap = false let client: any DSClient let ai: AIService /// DS base — 다운로드 등 파생 URL 의 단일 기준 (live()/preview 가 주입). let base: DSBaseURL /// 현재 access 토큰 (다운로드 ?token= 용). bootstrap/login 시 갱신. public private(set) var accessToken: String = "" public init(client: any DSClient, ai: AIService, base: DSBaseURL = .publicTLS) { self.client = client self.ai = ai self.base = base } @MainActor public static var preview: AppModel { AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter())) } /// 본 서버(GPU DS) 라이브 결선: LiveDSClient + 실 AIFabric 라우터(realRouter). ask 토큰 closure 는 /// client 의 TokenProvider 단일 소스(401 refresh 회전 반영). 토큰 영속화 기본 = InMemory — /// access 토큰은 15분 수명이라 영속 가치가 없고, 재시작 복귀는 HttpOnly refresh 쿠키(7일, /// HTTPCookieStorage 자동 보관)가 담당. Keychain 은 서명 배포 빌드에서 주입. @MainActor public static func live( base: DSBaseURL = .publicTLS, persistence: TokenPersistence = InMemoryTokenStore() ) -> AppModel { let client = LiveDSClient(base: base, persistence: persistence) let router = AppAIComposition.realRouter(base: base) { await client.currentAccessToken() } return AppModel(client: client, ai: AIService(router: router), base: base) } /// 앱 시작 1회 (single-shot — 새 창/뷰 재생성의 .task 재발화에도 중복 실행 안 함): /// refresh 쿠키로 토큰 복귀 시도. 401(쿠키 없음/만료) = 조용히 loggedOut(정상 신규 로그인 흐름) / /// 그 외(서버 도달 불가 등) = loggedOut + 사유를 loginError 로 노출(no-silent-fallback) / /// task 취소(창 닫힘) = 미시도로 복원해 다음 appear 에서 재시도. public func bootstrap() async { guard !didBootstrap else { return } didBootstrap = true // authPhase 는 초기값 .checking 그대로 사용 — 여기서 리셋하지 않아 ready 세션의 UI 플립 방지. do { let token = try await client.refresh().accessToken accessToken = token authPhase = .ready await loadInitial() } catch let e as DSError where e.isAuthExpired { authPhase = .loggedOut } catch { if Task.isCancelled { didBootstrap = false return } authPhase = .loggedOut loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)" } } /// 정식 로그인 (POST /auth/login → JWT). totp 는 개행/내부 공백 제거 후 비어 있으면 미전송. public func login(username: String, password: String, totp: String?) async { loginError = nil do { let code = totp.map { $0.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespacesAndNewlines) } let response = try await client.login( username: username, password: password, totpCode: (code?.isEmpty ?? true) ? nil : code ) accessToken = response.accessToken authPhase = .ready await loadInitial() } catch { loginError = (error as? LocalizedError)?.errorDescription ?? "\(error)" } } public func loadInitial() async { await guarded { self.tree = try await self.client.documentTree() } await guarded { self.stats = try await self.client.categoryCounts() } await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items } await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items } await guarded { self.digest = try await self.client.digest(date: nil, country: nil) } } public func openDocument(_ id: Int) async { selectedDocumentID = id await guarded { self.documentDetail = try await self.client.document(id: id) } } public func runSearch() async { guard !searchQuery.isEmpty else { return } await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) } } public func runAsk(backend: AIProviderID?) async { guard !askQuery.isEmpty else { return } askResult = await ai.corpusAsk(question: askQuery, explicit: backend) await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) } } public func openMemo(_ id: Int) async { selectedMemoID = id await guarded { self.memoDetail = try await self.client.memo(id: id) } } public func downloadURL(for doc: DocumentResponse) -> URL? { guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil } return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken) } private func guarded(_ work: () async throws -> Void) async { do { try await work() } catch let e as DSError where e.isAuthExpired { // LiveDSClient 내부 refresh+재시도까지 실패(refresh 쿠키 만료/폐기) — 세션 종료로 강등. authPhase = .loggedOut loginError = "세션이 만료되었습니다. 다시 로그인하세요." } catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" } await syncAccessToken() } /// 401 회전(LiveDSClient 내부 refresh) 후 다운로드 ?token= 사본 재동기화 — guarded 가 모든 /// 데이터 호출의 깔때기이므로 회전 발생 지점 직후 항상 거친다. 토큰 단일 소스 = TokenProvider. private func syncAccessToken() async { guard let live = client as? LiveDSClient, let t = await live.currentAccessToken() else { return } if t != accessToken { accessToken = t } } }