Files
hyungi_document_server/clients/ds-app/Sources/AppFeature/State/AppModel.swift
T
hyungi 05296b3166 feat(ds-app): macOS 앱 마무리 — 업로드·다운로드·로그아웃 + 4섹션 페이지
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바)
- FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리)
- 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴)
- 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저
  (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all
- 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:55:52 +09:00

311 lines
14 KiB
Swift

import SwiftUI
import Observation
import DSKit
import AIFabric
import UniformTypeIdentifiers
/// 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 {
/// = ···. (ask)·(AI chat) v1 macOS (2026-06-15)
/// AIFabric(S2) iPhone/Watch , UI .
public enum Section: String, CaseIterable, Identifiable, Hashable {
case dashboard, documents, digest, memos
public var id: String { rawValue }
public var title: String {
switch self {
case .dashboard: return ""
case .documents: return "문서"
case .digest: return "뉴스"
case .memos: return "메모"
}
}
}
/// : refresh (checking) (loggedOut)
/// (ready). Fixture refresh fixture ready.
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
/// / + . done/failed .
public enum UploadState: Equatable, Sendable {
case idle
case uploading(name: String)
case done(title: String)
case failed(String)
}
public var section: Section = .dashboard
public var selectedDocumentID: Int?
public var selectedMemoID: Int?
public var tree: [DomainTreeNode] = []
public var stats: CategoryCounts?
/// ( ). loadInitial count . nil=.
public var reviewPendingCount: Int?
/// ( ). loadInitial me() .
public var currentUser: UserResponse?
public private(set) var uploadState: UploadState = .idle
/// (CaptureCard , saveMemo ).
public var captureText: String = ""
public var documentList: [DocumentResponse] = []
public var documentDetail: DocumentDetailResponse?
/// ( path, nil = ).
public var documentDomainFilter: String?
/// ( load-all ). .
public private(set) var documentsFullyLoaded = false
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.currentUser = try await self.client.me() }
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) }
await guarded {
var q = DocumentListQuery(); q.reviewStatus = "pending"; q.pageSize = 1
self.reviewPendingCount = try await self.client.documents(q).total
}
}
public func openDocument(_ id: Int) async {
selectedDocumentID = id
await guarded { self.documentDetail = try await self.client.document(id: id) }
}
/// ( ). load-all.
public func ensureDocumentsLoaded() async {
if !documentsFullyLoaded { await loadDocuments(domain: documentDomainFilter) }
}
/// **** load-all ( page_size 100
/// 1582 ). append .
/// / 3-pane .
public func loadDocuments(domain: String?) async {
documentDomainFilter = domain
documentsFullyLoaded = false
documentList = []
let pageSize = 100
var page = 1
do {
while page <= 80 { // ~8000
var q = DocumentListQuery(); q.domain = domain; q.page = page; q.pageSize = pageSize
let resp = try await client.documents(q)
documentList.append(contentsOf: resp.items)
if resp.items.count < pageSize || documentList.count >= resp.total { break }
page += 1
}
documentsFullyLoaded = true
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
} catch {
errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)"
}
await syncAccessToken()
if let sel = selectedDocumentID, !documentList.contains(where: { $0.id == sel }) {
selectedDocumentID = nil
documentDetail = nil
}
}
/// . true. / (false).
/// guarded errorText ( ).
@discardableResult
public func saveMemo(_ text: String) async -> Bool {
let t = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty else { return false }
var ok = false
await guarded {
let memo = try await self.client.createMemo(MemoCreate(content: t))
self.memoList.insert(memo, at: 0)
ok = true
}
return ok
}
/// captureText , .
public func saveMemo() async {
if await saveMemo(captureText) { captureText = "" }
}
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)
}
/// : / (best-effort) loggedOut.
/// stale . .
public func logout() async {
try? await client.logout()
accessToken = ""
currentUser = nil
tree = []
stats = nil
reviewPendingCount = nil
captureText = ""
documentList = []
documentDetail = nil
documentDomainFilter = nil
documentsFullyLoaded = false
memoList = []
memoDetail = nil
digest = nil
selectedDocumentID = nil
selectedMemoID = nil
section = .dashboard // ( LOW: )
errorText = nil
uploadState = .idle
authPhase = .loggedOut
}
/// (NSOpenPanel URL) . IO uploadState .
public func uploadPicked(_ fileURL: URL) async {
let accessed = fileURL.startAccessingSecurityScopedResource()
defer { if accessed { fileURL.stopAccessingSecurityScopedResource() } }
let filename = fileURL.lastPathComponent
let data: Data
do {
data = try Data(contentsOf: fileURL)
} catch {
uploadState = .failed("파일을 읽을 수 없습니다: \((error as NSError).localizedDescription)")
return
}
let mime = UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType
await upload(DocumentUpload(filename: filename, data: data, mimeType: mime))
}
/// + . ( = ).
public func upload(_ payload: DocumentUpload) async {
uploadState = .uploading(name: payload.filename)
do {
let doc = try await client.uploadDocument(payload)
uploadState = .done(title: doc.title ?? doc.downloadLabel)
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
} catch let e as DSError where e.isAuthExpired {
authPhase = .loggedOut
loginError = "세션이 만료되었습니다. 다시 로그인하세요."
uploadState = .failed("세션이 만료되었습니다.")
} catch {
uploadState = .failed((error as? LocalizedError)?.errorDescription ?? "\(error)")
}
await syncAccessToken()
}
/// (done/failed ).
public func dismissUploadStatus() { uploadState = .idle }
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 }
}
}