05296b3166
- 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>
311 lines
14 KiB
Swift
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 }
|
|
}
|
|
}
|