diff --git a/clients/ds-app/App/DSApp.swift b/clients/ds-app/App/DSApp.swift index fbba2c8..d0986e0 100644 --- a/clients/ds-app/App/DSApp.swift +++ b/clients/ds-app/App/DSApp.swift @@ -1,16 +1,32 @@ import SwiftUI import AppFeature -/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider)) -/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in -/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial. +/// Thin @main entry: window + DI only. 기본 = 본 서버(GPU DS) 라이브 결선(AppModel.live — +/// LiveDSClient + 실 AIFabric 라우터, base 기본 publicTLS = https://document.hyungi.net/api). +/// 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 struct DSApp: App { @State private var model: AppModel @MainActor 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 { diff --git a/clients/ds-app/Package.swift b/clients/ds-app/Package.swift index f73f6cd..98e1804 100644 --- a/clients/ds-app/Package.swift +++ b/clients/ds-app/Package.swift @@ -47,6 +47,11 @@ let package = Package( dependencies: ["DSKit", "AIFabric"], swiftSettings: [.swiftLanguageMode(.v6)] ), + .testTarget( + name: "AppFeatureTests", + dependencies: ["AppFeature", "DSKit"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), .testTarget( name: "AITests", dependencies: ["AIFabric"], diff --git a/clients/ds-app/Sources/AppFeature/Pages/LoginView.swift b/clients/ds-app/Sources/AppFeature/Pages/LoginView.swift new file mode 100644 index 0000000..ea4dcf6 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/LoginView.swift @@ -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 diff --git a/clients/ds-app/Sources/AppFeature/Shell/RootView.swift b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift index cecc501..f327141 100644 --- a/clients/ds-app/Sources/AppFeature/Shell/RootView.swift +++ b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift @@ -3,6 +3,7 @@ import DSKit /// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment /// (no shell-level auto-inherit). macOS-only target. +/// 인증 게이트: checking(부팅 시 refresh 쿠키 복귀 시도) → loggedOut(LoginView) → ready(3-pane 셸). public struct RootView: View { @Environment(AppModel.self) private var model @State private var columnVisibility: NavigationSplitViewVisibility = .all @@ -10,6 +11,22 @@ public struct RootView: View { public init() {} 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) { Sidebar() .navigationSplitViewColumnWidth(min: 220, ideal: 250) @@ -21,7 +38,24 @@ public struct RootView: View { } .navigationSplitViewStyle(.balanced) .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 RootView() .environment(model) - .task { await model.loadInitial() } .frame(minWidth: 1000, minHeight: 660) } #endif diff --git a/clients/ds-app/Sources/AppFeature/State/AppModel.swift b/clients/ds-app/Sources/AppFeature/State/AppModel.swift index 41d2242..0bb0f42 100644 --- a/clients/ds-app/Sources/AppFeature/State/AppModel.swift +++ b/clients/ds-app/Sources/AppFeature/State/AppModel.swift @@ -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 selectedDocumentID: Int? public var selectedMemoID: Int? @@ -41,14 +45,23 @@ public final class AppModel { 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 - /// 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 init(client: any DSClient, ai: AIService) { + public init(client: any DSClient, ai: AIService, base: DSBaseURL = .publicTLS) { self.client = client self.ai = ai + self.base = base } @MainActor @@ -56,8 +69,66 @@ public final class 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.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.stats = try await self.client.categoryCounts() } 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? { 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 { - do { try await work() } - catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + 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 } } } diff --git a/clients/ds-app/Sources/DSKit/LiveDSClient.swift b/clients/ds-app/Sources/DSKit/LiveDSClient.swift index 9af4531..23fe87c 100644 --- a/clients/ds-app/Sources/DSKit/LiveDSClient.swift +++ b/clients/ds-app/Sources/DSKit/LiveDSClient.swift @@ -39,6 +39,9 @@ public final class LiveDSClient: DSClient, @unchecked Sendable { 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 private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest { diff --git a/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift b/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift new file mode 100644 index 0000000..62235e1 --- /dev/null +++ b/clients/ds-app/Tests/AppFeatureTests/AppModelAuthTests.swift @@ -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) } +}