feat(ds-app): 본 서버(GPU DS) 라이브 결선 — 앱 기본을 오프라인 스캐폴드에서 라이브로 전환

- AppModel: AuthPhase 상태기계(checking/loggedOut/ready) + live() 팩토리
  (LiveDSClient + realRouter, ask 토큰 = TokenProvider 단일 소스) + bootstrap
  (refresh 쿠키 무로그인 복귀, single-shot, 취소 시 재시도 복원) + login(TOTP
  개행·공백 정규화) + 사용 중 세션 만료 시 loggedOut 강등 + 401 회전 후
  다운로드 ?token= 사본 재동기화(guarded 깔때기)
- LoginView 신규(기능 셸, 서버 host 표시, 서버 detail 메시지 노출)
- RootView: 인증 게이트 + errorText 하단 배너(no-silent-fallback 가시화)
- DSApp: 기본 .live(publicTLS=document.hyungi.net/api), DSAPP_FIXTURE=1 /
  DSAPP_DS_URL env 스위치(파싱 실패 = fail-loud, prod silent fallback 금지)
- LiveDSClient.currentAccessToken() — realRouter ask 토큰 closure 용
- AppFeatureTests 신규 10건(인증 상태기계·single-shot·transport 사유·totp)

검증: swift test 82/82 green + xcodebuild .app BUILD SUCCEEDED + 라이브
negative-path(/auth/login 401·/auth/refresh 401, 본 서버 양 경로 도달).
3-렌즈 어드버서리얼 리뷰 반영(재진입 가드/transport 구분/env fail-loud/토큰
사본 동기화/만료 강등). Sources/AI 무수정(시그니처 동결 준수).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-07 14:32:51 +09:00
parent 9ffbdc0c23
commit f1dc2e1a8d
7 changed files with 434 additions and 12 deletions
+20 -4
View File
@@ -1,16 +1,32 @@
import SwiftUI import SwiftUI
import AppFeature import AppFeature
/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider)) /// Thin @main entry: window + DI only. = (GPU DS) (AppModel.live
/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in /// LiveDSClient + AIFabric , base publicTLS = https://document.hyungi.net/api).
/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial. /// env : DSAPP_FIXTURE=1 (Fixture+Mock) / DSAPP_DS_URL base
/// (: http://100.110.63.63:8000/api). Feature logic lives in AppFeature, keeping the seam to a
/// future iPhone/Watch target trivial.
@main @main
struct DSApp: App { struct DSApp: App {
@State private var model: AppModel @State private var model: AppModel
@MainActor @MainActor
init() { init() {
_model = State(initialValue: AppModel.preview) let env = ProcessInfo.processInfo.environment
let initial: AppModel
if env["DSAPP_FIXTURE"] == "1" {
initial = .preview
} else if let raw = env["DSAPP_DS_URL"] {
// dev prod(publicTLS) silent fallback , .
let trimmed = raw.hasSuffix("/") ? String(raw.dropLast()) : raw
guard let url = URL(string: trimmed), url.scheme != nil, url.host() != nil else {
fatalError("DSAPP_DS_URL 파싱 실패: \(raw)")
}
initial = .live(base: .custom(url))
} else {
initial = .live()
}
_model = State(initialValue: initial)
} }
var body: some Scene { var body: some Scene {
+5
View File
@@ -47,6 +47,11 @@ let package = Package(
dependencies: ["DSKit", "AIFabric"], dependencies: ["DSKit", "AIFabric"],
swiftSettings: [.swiftLanguageMode(.v6)] swiftSettings: [.swiftLanguageMode(.v6)]
), ),
.testTarget(
name: "AppFeatureTests",
dependencies: ["AppFeature", "DSKit"],
swiftSettings: [.swiftLanguageMode(.v6)]
),
.testTarget( .testTarget(
name: "AITests", name: "AITests",
dependencies: ["AIFabric"], dependencies: ["AIFabric"],
@@ -0,0 +1,97 @@
import SwiftUI
import DSKit
/// (GPU DS) ( FU-E ).
/// refresh / ; HttpOnly refresh
/// . TOTP ( ).
public struct LoginView: View {
@Environment(AppModel.self) private var model
@State private var username = ""
@State private var password = ""
@State private var totp = ""
@State private var submitting = false
@FocusState private var focus: Field?
private enum Field { case username, password, totp }
public init() {}
public var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Document Server")
.font(.title2.weight(.semibold))
.foregroundStyle(Sage.ink)
Text(serverHost)
.font(.caption)
.foregroundStyle(Sage.muted)
}
VStack(spacing: 10) {
TextField("아이디", text: $username)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .username)
.onSubmit { focus = .password }
SecureField("비밀번호", text: $password)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .password)
.onSubmit { submit() }
TextField("2FA 코드 (설정한 경우)", text: $totp)
.textFieldStyle(.roundedBorder)
.focused($focus, equals: .totp)
.onSubmit { submit() }
}
if let error = model.loginError {
Text(error)
.font(.callout)
.foregroundStyle(Sage.danger)
.fixedSize(horizontal: false, vertical: true)
}
Button(action: submit) {
Group {
if submitting {
ProgressView().controlSize(.small)
} else {
Text("로그인")
}
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Sage.brand)
.disabled(submitting || username.isEmpty || password.isEmpty)
}
.padding(28)
.frame(width: 360)
.background(Sage.card, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line))
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
.onAppear { focus = .username }
}
/// base host (: document.hyungi.net / 100.110.63.63).
private var serverHost: String {
model.base.url.host() ?? model.base.url.absoluteString
}
private func submit() {
guard !submitting, !username.isEmpty, !password.isEmpty else { return }
submitting = true
Task {
await model.login(username: username, password: password, totp: totp)
submitting = false
}
}
}
#if DEBUG
#Preview("로그인") {
@Previewable @State var model = AppModel.preview
LoginView()
.environment(model)
.frame(width: 700, height: 500)
}
#endif
@@ -3,6 +3,7 @@ import DSKit
/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment /// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment
/// (no shell-level auto-inherit). macOS-only target. /// (no shell-level auto-inherit). macOS-only target.
/// : checking( refresh ) loggedOut(LoginView) ready(3-pane ).
public struct RootView: View { public struct RootView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var columnVisibility: NavigationSplitViewVisibility = .all
@@ -10,6 +11,22 @@ public struct RootView: View {
public init() {} public init() {}
public var body: some View { public var body: some View {
Group {
switch model.authPhase {
case .checking:
ProgressView("서버 연결 확인 중")
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Sage.surface)
case .loggedOut:
LoginView()
case .ready:
shell
}
}
.task { await model.bootstrap() }
}
private var shell: some View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
Sidebar() Sidebar()
.navigationSplitViewColumnWidth(min: 220, ideal: 250) .navigationSplitViewColumnWidth(min: 220, ideal: 250)
@@ -21,7 +38,24 @@ public struct RootView: View {
} }
.navigationSplitViewStyle(.balanced) .navigationSplitViewStyle(.balanced)
.tint(Sage.brand) .tint(Sage.brand)
.task { await model.loadInitial() } .safeAreaInset(edge: .bottom) {
// (no-silent-fallback) .
if let err = model.errorText {
HStack(spacing: 10) {
Text(err)
.font(.callout)
.foregroundStyle(.white)
.lineLimit(2)
Spacer()
Button("닫기") { model.errorText = nil }
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(Sage.danger)
}
}
} }
} }
@@ -120,7 +154,6 @@ struct EmptyState: View {
@Previewable @State var model = AppModel.preview @Previewable @State var model = AppModel.preview
RootView() RootView()
.environment(model) .environment(model)
.task { await model.loadInitial() }
.frame(minWidth: 1000, minHeight: 660) .frame(minWidth: 1000, minHeight: 660)
} }
#endif #endif
@@ -23,6 +23,10 @@ public final class AppModel {
} }
} }
/// : refresh (checking) (loggedOut)
/// (ready). Fixture refresh fixture ready.
public enum AuthPhase: Equatable { case checking, loggedOut, ready }
public var section: Section = .dashboard public var section: Section = .dashboard
public var selectedDocumentID: Int? public var selectedDocumentID: Int?
public var selectedMemoID: Int? public var selectedMemoID: Int?
@@ -41,14 +45,23 @@ public final class AppModel {
public var digest: DigestResponse? public var digest: DigestResponse?
public var errorText: String? 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 client: any DSClient
let ai: AIService let ai: AIService
/// Placeholder token from the auth fixture builds a real-SHAPED download URL with no expectation it resolves offline. /// DS base URL (live()/preview ).
let base: DSBaseURL
/// access ( ?token= ). bootstrap/login .
public private(set) var accessToken: String = "" public private(set) var accessToken: String = ""
public init(client: any DSClient, ai: AIService) { public init(client: any DSClient, ai: AIService, base: DSBaseURL = .publicTLS) {
self.client = client self.client = client
self.ai = ai self.ai = ai
self.base = base
} }
@MainActor @MainActor
@@ -56,8 +69,66 @@ public final class AppModel {
AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter())) 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 { public func loadInitial() async {
await guarded { self.accessToken = (try? await self.client.login(username: "hyungi", password: "x", totpCode: nil).accessToken) ?? "" }
await guarded { self.tree = try await self.client.documentTree() } await guarded { self.tree = try await self.client.documentTree() }
await guarded { self.stats = try await self.client.categoryCounts() } await guarded { self.stats = try await self.client.categoryCounts() }
await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items } await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items }
@@ -88,11 +159,26 @@ public final class AppModel {
public func downloadURL(for doc: DocumentResponse) -> URL? { public func downloadURL(for doc: DocumentResponse) -> URL? {
guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil } guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil }
return DSDownload.fileURL(base: .publicTLS, documentID: doc.id, accessToken: accessToken) return DSDownload.fileURL(base: base, documentID: doc.id, accessToken: accessToken)
} }
private func guarded(_ work: () async throws -> Void) async { private func guarded(_ work: () async throws -> Void) async {
do { try await work() } do {
catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" } 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 }
} }
} }
@@ -39,6 +39,9 @@ public final class LiveDSClient: DSClient, @unchecked Sendable {
public func setAccessToken(_ token: String) async { await tokens.set(token) } public func setAccessToken(_ token: String) async { await tokens.set(token) }
/// realRouter ask closure TokenProvider (401 refresh ).
public func currentAccessToken() async -> String? { await tokens.current() }
// MARK: - Request building / sending // MARK: - Request building / sending
private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest { private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest {
@@ -0,0 +1,182 @@
import XCTest
@testable import AppFeature
import DSKit
/// 0 (Fixture/stub ).
/// bootstrap: refresh =ready / =loggedOut. login: =ready+ / 401= .
final class AppModelAuthTests: XCTestCase {
@MainActor
private func makeModel(client: any DSClient) -> AppModel {
AppModel(client: client, ai: AIService(router: AppAIComposition.mockRouter()))
}
// refresh ( Fixture fixture ) ready +
@MainActor
func testBootstrapRefreshSuccessGoesReady() async {
let model = AppModel.preview
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.accessToken.isEmpty)
XCTAssertFalse(model.documentList.isEmpty, "ready 진입 시 초기 로드까지 수행해야 함")
}
// refresh ( /) loggedOut,
@MainActor
func testBootstrapRefreshFailureGoesLoggedOut() async {
let model = makeModel(client: AuthStubClient(refreshFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertTrue(model.accessToken.isEmpty)
XCTAssertTrue(model.documentList.isEmpty)
}
// loggedOut login ready +
@MainActor
func testLoginSuccessTransitionsToReady() async {
let model = makeModel(client: AuthStubClient(refreshFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
await model.login(username: "hyungi", password: "pw", totp: nil)
XCTAssertEqual(model.authPhase, .ready)
XCTAssertFalse(model.accessToken.isEmpty)
XCTAssertNil(model.loginError)
XCTAssertFalse(model.documentList.isEmpty)
}
// login 401 loginError + loggedOut +
@MainActor
func testLoginFailureSurfacesErrorAndStaysLoggedOut() async {
let model = makeModel(client: AuthStubClient(refreshFails: true, loginFails: true))
await model.bootstrap()
await model.login(username: "hyungi", password: "wrong", totp: nil)
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError)
XCTAssertTrue(model.accessToken.isEmpty)
}
// totp / totpCode nil ( totp )
@MainActor
func testLoginSendsNilForBlankTotp() async {
let stub = AuthStubClient(refreshFails: true)
let model = makeModel(client: stub)
await model.login(username: "u", password: "p", totp: " ")
XCTAssertNotNil(stub.recordedLogin, "login 이 호출돼야 함")
XCTAssertNil(stub.recordedLogin?.totp, "공백 totp 는 nil 로 정규화")
await model.login(username: "u", password: "p", totp: "123456")
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
}
// totp (/ ) "123 456\n" "123456"
@MainActor
func testLoginNormalizesTotpNewlineAndSpaces() async {
let stub = AuthStubClient(refreshFails: true)
let model = makeModel(client: stub)
await model.login(username: "u", password: "p", totp: "123 456\n")
XCTAssertEqual(stub.recordedLogin?.totp, "123456")
await model.login(username: "u", password: "p", totp: " \n ")
XCTAssertNil(stub.recordedLogin?.totp, "개행+공백뿐이면 nil")
}
// bootstrap single-shot (.task ) refresh 1, ready
@MainActor
func testBootstrapIsSingleShot() async {
let stub = AuthStubClient()
let model = makeModel(client: stub)
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
await model.bootstrap() // appear
XCTAssertEqual(model.authPhase, .ready, "재진입이 checking 으로 리셋하면 안 됨")
XCTAssertEqual(stub.refreshCount, 1, "refresh 는 1회만")
}
// bootstrap transport ( ) loggedOut + ( )
@MainActor
func testBootstrapTransportFailureExposesReason() async {
let model = makeModel(client: AuthStubClient(refreshTransportFails: true))
await model.bootstrap()
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError, "transport 실패 사유가 로그인 화면에 노출돼야 함")
}
// ( refresh+ ) ready loggedOut
@MainActor
func testAuthExpiredDuringUseDemotesToLoggedOut() async {
let stub = AuthStubClient()
let model = makeModel(client: stub)
await model.bootstrap()
XCTAssertEqual(model.authPhase, .ready)
stub.dataAuthExpired = true // 401 (refresh )
await model.openDocument(1)
XCTAssertEqual(model.authPhase, .loggedOut)
XCTAssertNotNil(model.loginError)
}
// live : LiveDSClient + base ( )
@MainActor
func testLiveFactoryComposition() {
let model = AppModel.live(base: .tailscale)
XCTAssertTrue(model.client is LiveDSClient)
XCTAssertEqual(model.base.url.absoluteString, DSBaseURL.tailscale.url.absoluteString)
}
}
/// FixtureDSClient + ( 0).
/// task @unchecked Sendable .
final class AuthStubClient: DSClient, @unchecked Sendable {
private let inner = FixtureDSClient()
private let refreshFails: Bool
private let refreshTransportFails: Bool
private let loginFails: Bool
private(set) var recordedLogin: (username: String, totp: String?)?
private(set) var refreshCount = 0
/// true 401 ( LiveDSClient )
var dataAuthExpired = false
init(refreshFails: Bool = false, refreshTransportFails: Bool = false, loginFails: Bool = false) {
self.refreshFails = refreshFails
self.refreshTransportFails = refreshTransportFails
self.loginFails = loginFails
}
private func gateData() throws {
if dataAuthExpired { throw DSError.unauthorized(message: nil) }
}
// Auth
func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse {
recordedLogin = (username, totpCode)
if loginFails { throw DSError.unauthorized(message: "아이디 또는 비밀번호가 올바르지 않습니다") }
return try await inner.login(username: username, password: password, totpCode: totpCode)
}
func refresh() async throws -> AccessTokenResponse {
refreshCount += 1
if refreshTransportFails { throw DSError.transport(underlying: "Could not connect to the server") }
if refreshFails { throw DSError.unauthorized(message: "refresh failed") }
return try await inner.refresh()
}
func me() async throws -> UserResponse { try await inner.me() }
func logout() async throws { try await inner.logout() }
// Fixture (dataAuthExpired )
func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try gateData(); return try await inner.documents(query) }
func document(id: Int) async throws -> DocumentDetailResponse { try gateData(); return try await inner.document(id: id) }
func documentContent(id: Int) async throws -> DocumentContentResponse { try await inner.documentContent(id: id) }
func documentTree() async throws -> [DomainTreeNode] { try await inner.documentTree() }
func categoryCounts() async throws -> CategoryCounts { try await inner.categoryCounts() }
func duplicates() async throws -> DuplicatesResponse { try await inner.duplicates() }
func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await inner.patchDocument(id: id, update) }
func putContent(id: Int, content: String) async throws { try await inner.putContent(id: id, content: content) }
func deleteDocument(id: Int) async throws { try await inner.deleteDocument(id: id) }
func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await inner.search(q: q, mode: mode, page: page, debug: debug) }
func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await inner.ask(q: q, limit: limit, backend: backend, debug: debug) }
func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await inner.memos(query) }
func memo(id: Int) async throws -> MemoResponse { try await inner.memo(id: id) }
func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await inner.createMemo(create) }
func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await inner.patchMemo(id: id, update) }
func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await inner.pinMemo(id: id, pinned: pinned) }
func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await inner.archiveMemo(id: id, archived: archived) }
func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await inner.toggleMemoTask(id: id, taskIndex: taskIndex, checked: checked) }
func deleteMemo(id: Int) async throws { try await inner.deleteMemo(id: id) }
func digest(date: String?, country: String?) async throws -> DigestResponse { try await inner.digest(date: date, country: country) }
}