From 8b78419d126c55df7ad950ee7ae85f375b5bbec6 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 28 Oct 2026 22:28:45 +0800 Subject: [PATCH] feat(server): support kmrs updates and session management --- .agents/skills/repo-conventions/SKILL.md | 2 +- KMReader/Core/Storage/AppConfig.swift | 28 + KMReader/Core/Storage/ManagementService.swift | 87 +++ .../Server/Models/NativeServerInfo.swift | 19 + .../Server/Models/NativeServerSession.swift | 25 + .../Server/Models/ServerCapability.swift | 50 ++ .../Server/Models/ServerSession.swift | 62 ++ .../Server/Models/ServerSessionScope.swift | 10 + .../Server/Models/ServerUpdateCheck.swift | 21 + KMReader/Features/Server/ServerInfoView.swift | 78 ++- KMReader/Features/Server/ServerSection.swift | 7 + .../Features/Server/ServerSessionRow.swift | 80 +++ .../Features/Server/ServerSessionsView.swift | 195 +++--- .../Server/ServerUpdateCheckRow.swift | 63 ++ KMReader/Features/Server/ServerView.swift | 9 + .../Settings/SettingsAccountView.swift | 7 + KMReader/Localizable.xcstrings | 640 ++++++++++++++++++ .../Foundation/Models/NavDestination.swift | 3 + 18 files changed, 1269 insertions(+), 117 deletions(-) create mode 100644 KMReader/Features/Server/Models/NativeServerInfo.swift create mode 100644 KMReader/Features/Server/Models/NativeServerSession.swift create mode 100644 KMReader/Features/Server/Models/ServerCapability.swift create mode 100644 KMReader/Features/Server/Models/ServerSession.swift create mode 100644 KMReader/Features/Server/Models/ServerSessionScope.swift create mode 100644 KMReader/Features/Server/Models/ServerUpdateCheck.swift create mode 100644 KMReader/Features/Server/ServerSessionRow.swift create mode 100644 KMReader/Features/Server/ServerUpdateCheckRow.swift diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index 8790f92f7..175dbd088 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -169,7 +169,7 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r ### Library Selection - Two separate concepts share the library scope UI: the **pinned set** (`dashboard.libraryIds`, empty = all libraries) is the persisted aggregate scope — per-instance via `DashboardLibrarySelectionStore` (UserDefaults `dashboard` config + GRDB `komga_instances.selected_library_ids_raw`), edited through the checklist in `LibraryPickerSheet` and consumed by the Dashboard, the iPhone Library/Search tab roots, Offline, search, and widgets. The **session scope** (a `LibraryBrowseScope` of `.pinned`/`.all`/`.library(id)`, default `.pinned`, resolved via `resolvedIds(pinned:)`) is per-surface view state: the Dashboard keeps it in `DashboardLibraryScopeStore.shared`, the iPhone Library/Search tabs, Offline, and pushed browse pages keep it in local `@State`, and iPad/macOS browse roots take it from the shell (next bullet); never persisted, the dashboard's resets in `DashboardLibrarySelectionStore.loadSelection` (app start and server switch) and when the scoped library vanishes from the loaded list (browse pages reset the same way); dashboard sections fetch with `effectiveLibraryIds(pinned:)` while widgets always follow the pinned set. -- Scope identity renders in two forms, both fed by `LibraryBrowseScope.title(pinnedIds:libraries:)` / `.facts(pinnedIds:libraries:allLibrariesEntry:)` (the shared title text and covered-metrics item; metrics are admin-only: `.all`/empty-`.pinned` read the server's all-libraries entry since overlapping roots make client-side sums overstate file size, a non-empty `.pinned` sums the covered libraries client-side, `.library(id)` uses the library's own row). The **Dashboard** shows `LibraryScopeHeader` (Dashboard/Views): icon matching the scope menu (`books.vertical` for All/a library, `pin` for Pinned), the title, and a single truncating facts line (file size first, then series/books — no sidecars — via `LibraryMetricsText.sizeAndMetrics`). **Browse pages and Offline** instead show a caption trailing the content-type chip — the scope title set off from the facts that follow in secondary: just the file size on browse pages (the series/books counts ride the chip itself in parentheses, from the same scope facts item; collections/read lists carry no counts), just the total downloaded size on Offline (the downloaded series/books counts ride the chip in parentheses, from `fetchDownloadedSeriesCount` and `fetchOfflineBooksStats(instanceId:libraryIds:)` — both matching their offline list filters, refreshed on scope/instance change and on `DownloadProgressTracker.queueUpdateToken`). `LibraryScopeStore.refreshMetrics` reloads the metrics (admin-gated, no-op offline) on every dashboard refresh and browse-page library refresh; full reloads stay with `LibraryPickerSheet` and Settings → Libraries. Library metrics prefer the kmrs-native stats endpoints (`/api/v1/stats/libraries` for per-library counts and the all-libraries total) and fall back to the Komga actuator metrics (`komga.books.filesize`/`komga.series`/`komga.books`, untagged totals plus per-library tags) when they 404 — the fallback covers only library size/series/books, so sidecar counts simply never appear on Komga; per-instance verdicts live in `AppConfig.serverStatsCapability` (24h recheck, the same probe pattern as reading stats), and the stored numbers are cleared only when both sources fail, so previously stored numbers never linger. Server metrics come only from `/api/v1/stats/server` — task, process, and global stats — with no actuator fallback. Sidecar counts (per-library and total) ride the `/stats/libraries` payload but are only sent to admins; the payload carries no collections/read lists counts, and library-level RL/C counts are not surfaced anywhere. The Server Info page works on both servers: on kmrs it loads `/stats/server` (process snapshot, content totals, per-type task metrics) plus the actuator info/health endpoints, and on Komga the same rows fall back to per-metric actuator reads (`process.*` for CPU/uptime/start/memory — CPU percent vs 0-1 ratio told apart by the meter's `baseUnit` — and the `komga.*` gauges for content); the shared `/stats/server` 404 probe (`AppConfig.serverStatsCapability`) picks the path. The overview shows server identity (version, commit, OS), process and resources (start time, uptime, CPU, memory, disk), and content totals, plus maintenance actions (log file download via share sheet, hidden when the HEAD probe 404s as on stock Komga; server shutdown behind confirmation); the task queue, sessions, and scheduled tasks are sub-pages — Tasks pairs the SSE live queue and Cancel All with the per-type execution metrics (runs/total/longest/failures, from `/stats/server` on kmrs and the `komga.tasks.*` meters on Komga), Sessions lists `/actuator/sessions` with per-session kick (kmrs lists all users, Komga rejects the cross-user listing and falls back to the current user's `?username=` query), Scheduled Tasks lists the actuator fixed-rate registrations (Java FQN targets shortened to their meaningful tail). A failed load keeps the previously stored numbers. +- Scope identity renders in two forms, both fed by `LibraryBrowseScope.title(pinnedIds:libraries:)` / `.facts(pinnedIds:libraries:allLibrariesEntry:)` (the shared title text and covered-metrics item; metrics are admin-only: `.all`/empty-`.pinned` read the server's all-libraries entry since overlapping roots make client-side sums overstate file size, a non-empty `.pinned` sums the covered libraries client-side, `.library(id)` uses the library's own row). The **Dashboard** shows `LibraryScopeHeader` (Dashboard/Views): icon matching the scope menu (`books.vertical` for All/a library, `pin` for Pinned), the title, and a single truncating facts line (file size first, then series/books — no sidecars — via `LibraryMetricsText.sizeAndMetrics`). **Browse pages and Offline** instead show a caption trailing the content-type chip — the scope title set off from the facts that follow in secondary: just the file size on browse pages (the series/books counts ride the chip itself in parentheses, from the same scope facts item; collections/read lists carry no counts), just the total downloaded size on Offline (the downloaded series/books counts ride the chip in parentheses, from `fetchDownloadedSeriesCount` and `fetchOfflineBooksStats(instanceId:libraryIds:)` — both matching their offline list filters, refreshed on scope/instance change and on `DownloadProgressTracker.queueUpdateToken`). `LibraryScopeStore.refreshMetrics` reloads the metrics (admin-gated, no-op offline) on every dashboard refresh and browse-page library refresh; full reloads stay with `LibraryPickerSheet` and Settings → Libraries. Library metrics prefer the kmrs-native stats endpoints (`/api/v1/stats/libraries` for per-library counts and the all-libraries total) and fall back to the Komga actuator metrics (`komga.books.filesize`/`komga.series`/`komga.books`, untagged totals plus per-library tags) when they 404 — the fallback covers only library size/series/books, so sidecar counts simply never appear on Komga; per-instance verdicts live in `AppConfig.serverStatsCapability` (24h recheck, the same probe pattern as reading stats), and the stored numbers are cleared only when both sources fail, so previously stored numbers never linger. Server metrics come only from `/api/v1/stats/server` — task, process, and global stats — with no actuator fallback. Sidecar counts (per-library and total) ride the `/stats/libraries` payload but are only sent to admins; the payload carries no collections/read lists counts, and library-level RL/C counts are not surfaced anywhere. The Server Info page works on both servers: on kmrs it loads `/stats/server` (process snapshot, content totals, per-type task metrics) and `/api/v1/server/info` (flat version/git identity), with actuator health and metrics as fallback for other servers; the shared `/stats/server` 404 probe (`AppConfig.serverStatsCapability`) and independent `/api/v1/server/info` 404 probe (`AppConfig.serverManagementCapability`) pick those paths. The overview shows server identity (version, commit, OS), process and resources (start time, uptime, CPU, memory, disk), content totals, and the admin `/api/v1/update-check` result when supported. Maintenance offers log file download via share sheet (hidden when the HEAD probe 404s as on stock Komga), plus server shutdown behind confirmation only when native kmrs is not confirmed — current kmrs has no HTTP shutdown endpoint, so never probe shutdown via POST. Tasks, sessions, and scheduled tasks are sub-pages: Tasks pairs the SSE live queue and Cancel All with per-type execution metrics (from `/stats/server` on kmrs and `komga.tasks.*` on Komga), and Scheduled Tasks lists actuator fixed-rate registrations (Java FQN targets shortened to their meaningful tail). Native session management uses the paginated `/api/v2/users/me/sessions` for the account page and `/api/v2/users/sessions` for admin sessions; independent 24h negative capability verdicts live in `AppConfig.serverSessionsCapability`. The admin page falls back to `/actuator/sessions`, then its current-user `?username=` variant on Komga; the account page never calls that admin-only fallback. Current sessions cannot be revoked from the UI, and native admin rows show owner email/user id. A failed load keeps previously stored numbers. A failed load keeps the previously stored numbers. - Browsing a single library is navigation on all platforms (the sidebar/tab bar selects `NavDestination.browseLibrary`). On iPad/macOS the Libraries section leads with the aggregate entries — "Pinned" (`.browse(scope: .pinned)`, only while the pinned set is non-empty) and "All Libraries" (`NavDestination.browse(scope: .all)`) — inside the same section as the individual libraries, and the pinned aggregate leads everywhere the two are listed (sidebar, tab section, scope menu) because pinned is the default scope on every surface — on iPad this also means the tab bar's Libraries item opens the pinned scope, since it opens the section's first tab (a separate aggregate section would land on the first library instead). The aggregate rows are plain text like the library rows (no icons, no separator — the iPad `TabSection` accepts only tabs), and the shells redirect a live pinned-aggregate selection to All when the last pin is removed. Browse roots on those platforms take their scope from the shell through the `\.libraryScopeBinding` environment key (a value-type `Binding` provided by `PadTabView`/`MainSplitView`): the scope menu writes through to the shell's selection, so an in-page pick switches sidebar row/tab and the highlight follows. Pushed browse pages get the binding cleared to nil (in `View+Navigation`) and keep a page-local session scope, the pushed selection only its initial value; on iPhone, pushed single-library pages carry no scope menu. ### Selection Mode diff --git a/KMReader/Core/Storage/AppConfig.swift b/KMReader/Core/Storage/AppConfig.swift index 536975c05..9689c7999 100644 --- a/KMReader/Core/Storage/AppConfig.swift +++ b/KMReader/Core/Storage/AppConfig.swift @@ -1141,6 +1141,34 @@ enum AppConfig { } } + static nonisolated var serverManagementCapability: ServerCapability { + get { + if let stored = UserDefaults.standard.string(forKey: "serverManagementCapability"), + let capability = ServerCapability(rawValue: stored) + { + return capability + } + return ServerCapability() + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: "serverManagementCapability") + } + } + + static nonisolated var serverSessionsCapability: ServerCapability { + get { + if let stored = UserDefaults.standard.string(forKey: "serverSessionsCapability"), + let capability = ServerCapability(rawValue: stored) + { + return capability + } + return ServerCapability() + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: "serverSessionsCapability") + } + } + static nonisolated var serverSmartListCapability: ServerSmartListCapability { get { if let stored = UserDefaults.standard.string(forKey: "serverSmartListCapability"), diff --git a/KMReader/Core/Storage/ManagementService.swift b/KMReader/Core/Storage/ManagementService.swift index 8e2da4417..9f0453ca1 100644 --- a/KMReader/Core/Storage/ManagementService.swift +++ b/KMReader/Core/Storage/ManagementService.swift @@ -39,6 +39,45 @@ nonisolated enum ManagementService { try await apiClient.request(path: "/actuator/info") } + static func getNativeInfoIfSupported() async throws -> ActuatorInfoResponse? { + guard shouldQueryNativeAPI else { return nil } + do { + let info: NativeServerInfo = try await apiClient.request(path: "/api/v1/server/info") + recordNativeCapability(supported: true) + return info.actuatorInfo + } catch let error as APIError { + guard case .notFound = error else { throw error } + recordNativeCapability(supported: false) + return nil + } + } + + static func getUpdateCheck() async throws -> ServerUpdateCheck? { + guard AppConfig.current.isAdmin else { + throw AppErrorType.operationNotAllowed(message: "Admin access required") + } + guard shouldQueryNativeAPI else { return nil } + do { + let check: ServerUpdateCheck = try await apiClient.request(path: "/api/v1/update-check") + recordNativeCapability(supported: true) + return check + } catch let error as APIError { + guard case .notFound = error else { throw error } + recordNativeCapability(supported: false) + return nil + } + } + + private static var shouldQueryNativeAPI: Bool { + AppConfig.serverManagementCapability.shouldQuery(instanceId: AppConfig.current.instanceId) + } + + private static func recordNativeCapability(supported: Bool) { + var capability = AppConfig.serverManagementCapability + capability.record(instanceId: AppConfig.current.instanceId, supported: supported) + AppConfig.serverManagementCapability = capability + } + static func getHealth() async throws -> ActuatorHealthResponse { guard AppConfig.current.isAdmin else { throw AppErrorType.operationNotAllowed(message: "Admin access required") @@ -73,6 +112,54 @@ nonisolated enum ManagementService { ) } + static func getNativeSessions(scope: ServerSessionScope, page: Int, size: Int) async throws + -> Page? + { + guard shouldQueryNativeSessions else { return nil } + let path = scope == .own ? "/api/v2/users/me/sessions" : "/api/v2/users/sessions" + do { + let page: Page = try await apiClient.request( + path: path, + queryItems: [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "size", value: "\(size)"), + ] + ) + recordNativeSessionsCapability(supported: true) + return Page( + content: page.content.map(\.serverSession), + pageable: page.pageable, + totalElements: page.totalElements, + totalPages: page.totalPages, + last: page.last, + size: page.size, + number: page.number, + numberOfElements: page.numberOfElements, + first: page.first, + empty: page.empty + ) + } catch let error as APIError { + guard case .notFound = error else { throw error } + recordNativeSessionsCapability(supported: false) + return nil + } + } + + static func deleteNativeSession(id: String, scope: ServerSessionScope) async throws { + let path = scope == .own ? "/api/v2/users/me/sessions/\(id)" : "/api/v2/users/sessions/\(id)" + let _: EmptyResponse = try await apiClient.request(path: path, method: "DELETE") + } + + private static var shouldQueryNativeSessions: Bool { + AppConfig.serverSessionsCapability.shouldQuery(instanceId: AppConfig.current.instanceId) + } + + private static func recordNativeSessionsCapability(supported: Bool) { + var capability = AppConfig.serverSessionsCapability + capability.record(instanceId: AppConfig.current.instanceId, supported: supported) + AppConfig.serverSessionsCapability = capability + } + static func deleteSession(id: String) async throws { guard AppConfig.current.isAdmin else { throw AppErrorType.operationNotAllowed(message: "Admin access required") diff --git a/KMReader/Features/Server/Models/NativeServerInfo.swift b/KMReader/Features/Server/Models/NativeServerInfo.swift new file mode 100644 index 000000000..f9536c01a --- /dev/null +++ b/KMReader/Features/Server/Models/NativeServerInfo.swift @@ -0,0 +1,19 @@ +// +// NativeServerInfo.swift +// + +import Foundation + +nonisolated struct NativeServerInfo: Decodable, Sendable { + let version: String + let gitBranch: String + let gitCommitId: String + + var actuatorInfo: ActuatorInfoResponse { + ActuatorInfoResponse( + git: .init(branch: gitBranch, commit: .init(id: gitCommitId, time: nil)), + build: .init(artifact: nil, name: nil, version: version, group: nil), + os: nil + ) + } +} diff --git a/KMReader/Features/Server/Models/NativeServerSession.swift b/KMReader/Features/Server/Models/NativeServerSession.swift new file mode 100644 index 000000000..403ce2621 --- /dev/null +++ b/KMReader/Features/Server/Models/NativeServerSession.swift @@ -0,0 +1,25 @@ +// +// NativeServerSession.swift +// + +import Foundation + +nonisolated struct NativeServerSession: Codable, Sendable { + let id: String + let creationTime: String + let lastAccessedTime: String + let current: Bool? + let userId: String? + let email: String? + + var serverSession: ServerSession { + ServerSession( + id: id, + creationTime: creationTime, + lastAccessedTime: lastAccessedTime, + isCurrent: current ?? false, + email: email, + userId: userId + ) + } +} diff --git a/KMReader/Features/Server/Models/ServerCapability.swift b/KMReader/Features/Server/Models/ServerCapability.swift new file mode 100644 index 000000000..bcbf0b02c --- /dev/null +++ b/KMReader/Features/Server/Models/ServerCapability.swift @@ -0,0 +1,50 @@ +// +// ServerCapability.swift +// + +import Foundation + +nonisolated struct ServerCapability: Equatable, RawRepresentable, Sendable { + typealias RawValue = String + + struct Record: Codable, Equatable, Sendable { + var supported: Bool + var checkedAt: Date + } + + var recordsByInstance: [String: Record] + + init(recordsByInstance: [String: Record] = [:]) { + self.recordsByInstance = recordsByInstance + } + + func shouldQuery(instanceId: String, now: Date = Date()) -> Bool { + guard let record = recordsByInstance[instanceId] else { return true } + return record.supported || now.timeIntervalSince(record.checkedAt) >= 24 * 60 * 60 + } + + mutating func record(instanceId: String, supported: Bool, checkedAt: Date = Date()) { + if supported, recordsByInstance[instanceId]?.supported == true { return } + recordsByInstance[instanceId] = Record(supported: supported, checkedAt: checkedAt) + } + + var rawValue: String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(recordsByInstance), + let json = String(data: data, encoding: .utf8) + else { return "{}" } + return json + } + + init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), !data.isEmpty else { + self.init() + return + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.init(recordsByInstance: (try? decoder.decode([String: Record].self, from: data)) ?? [:]) + } +} diff --git a/KMReader/Features/Server/Models/ServerSession.swift b/KMReader/Features/Server/Models/ServerSession.swift new file mode 100644 index 000000000..7c3d314cf --- /dev/null +++ b/KMReader/Features/Server/Models/ServerSession.swift @@ -0,0 +1,62 @@ +// +// ServerSession.swift +// + +import Foundation + +nonisolated struct ServerSession: Identifiable, Codable, Equatable, Sendable { + let id: String + let creationTime: String + let lastAccessedTime: String + let isCurrent: Bool + let expired: Bool + let email: String? + let userId: String? + + init( + id: String, + creationTime: String, + lastAccessedTime: String, + isCurrent: Bool = false, + expired: Bool = false, + email: String? = nil, + userId: String? = nil + ) { + self.id = id + self.creationTime = creationTime + self.lastAccessedTime = lastAccessedTime + self.isCurrent = isCurrent + self.expired = expired + self.email = email + self.userId = userId + } + + var createdAt: Date? { + Self.parseDate(creationTime) + } + + var lastAccessedAt: Date? { + Self.parseDate(lastAccessedTime) + } + + private static func parseDate(_ value: String) -> Date? { + if let date = try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(value) { + return date + } + return try? Date.ISO8601FormatStyle().parse(value) + } +} + +nonisolated extension ActuatorSessionsResponse.Session { + var serverSession: ServerSession { + ServerSession( + id: id, + creationTime: creationTime, + lastAccessedTime: lastAccessedTime, + isCurrent: false, + expired: expired, + email: nil, + userId: nil + ) + } +} diff --git a/KMReader/Features/Server/Models/ServerSessionScope.swift b/KMReader/Features/Server/Models/ServerSessionScope.swift new file mode 100644 index 000000000..a11ecfcdf --- /dev/null +++ b/KMReader/Features/Server/Models/ServerSessionScope.swift @@ -0,0 +1,10 @@ +// +// ServerSessionScope.swift +// + +import Foundation + +nonisolated enum ServerSessionScope: Sendable { + case own + case all +} diff --git a/KMReader/Features/Server/Models/ServerUpdateCheck.swift b/KMReader/Features/Server/Models/ServerUpdateCheck.swift new file mode 100644 index 000000000..96f11c4e6 --- /dev/null +++ b/KMReader/Features/Server/Models/ServerUpdateCheck.swift @@ -0,0 +1,21 @@ +// +// ServerUpdateCheck.swift +// + +import Foundation + +nonisolated struct ServerUpdateCheck: Decodable, Sendable { + let enabled: Bool + let checkedAt: Date? + let update: Update? + + nonisolated struct Update: Decodable, Sendable { + let version: String + let url: String + let releaseDate: Date? + + var releaseURL: URL? { + URL(string: url) + } + } +} diff --git a/KMReader/Features/Server/ServerInfoView.swift b/KMReader/Features/Server/ServerInfoView.swift index c6ec52c45..ef8f01dc3 100644 --- a/KMReader/Features/Server/ServerInfoView.swift +++ b/KMReader/Features/Server/ServerInfoView.swift @@ -15,6 +15,8 @@ struct ServerInfoView: View { @AppStorage("taskQueueStatus") private var taskQueueStatus: TaskQueueSSEDto = TaskQueueSSEDto() @State private var serverInfo: ActuatorInfoResponse? + @State private var updateCheck: ServerUpdateCheck? + @State private var nativeServerConfirmed = false @State private var process = ProcessInfo() @State private var content = ContentCounts() @State private var isLoading = false @@ -75,6 +77,12 @@ struct ServerInfoView: View { } } + if let updateCheck { + Section(header: Text(String(localized: "Updates"))) { + ServerUpdateCheckRow(check: updateCheck) + } + } + if process.cpuPercent != nil || process.memoryBytes != nil || process.diskFreeBytes != nil { Section(header: Text(String(localized: "Resources"))) { if let cpuPercent = process.cpuPercent { @@ -161,40 +169,44 @@ struct ServerInfoView: View { } } - Section(header: Text(String(localized: "Maintenance"))) { - #if os(iOS) || os(macOS) - if logfileAvailable { - Button { - downloadLogFile() + if showsMaintenance { + Section(header: Text(String(localized: "Maintenance"))) { + #if os(iOS) || os(macOS) + if logfileAvailable { + Button { + downloadLogFile() + } label: { + HStack { + Label(String(localized: "Download Log File"), systemImage: "doc.text") + Spacer() + if isDownloadingLogs { + ProgressView() + } + } + .contentShape(Rectangle()) + } + .disabled(isDownloadingLogs) + .tvFocusableHighlight() + } + #endif + + if !nativeServerConfirmed { + Button(role: .destructive) { + showShutdownConfirmation = true } label: { HStack { - Label(String(localized: "Download Log File"), systemImage: "doc.text") + Label(String(localized: "Shut Down Server"), systemImage: "power") Spacer() - if isDownloadingLogs { + if isShuttingDown { ProgressView() } } .contentShape(Rectangle()) } - .disabled(isDownloadingLogs) + .disabled(isShuttingDown) .tvFocusableHighlight() } - #endif - - Button(role: .destructive) { - showShutdownConfirmation = true - } label: { - HStack { - Label(String(localized: "Shut Down Server"), systemImage: "power") - Spacer() - if isShuttingDown { - ProgressView() - } - } - .contentShape(Rectangle()) } - .disabled(isShuttingDown) - .tvFocusableHighlight() } } } @@ -231,6 +243,14 @@ struct ServerInfoView: View { } } + private var showsMaintenance: Bool { + #if os(iOS) || os(macOS) + return logfileAvailable || !nativeServerConfirmed + #else + return !nativeServerConfirmed + #endif + } + private var commitDescription: String? { guard let git = serverInfo?.git else { return nil } let parts = [git.branch, git.commit?.id].compactMap { $0 }.filter { !$0.isEmpty } @@ -268,8 +288,18 @@ struct ServerInfoView: View { guard !AppConfig.isOffline else { return } isLoading = true + async let nativeInfoRequest = ManagementService.getNativeInfoIfSupported() + async let updateCheckRequest = ManagementService.getUpdateCheck() + do { - serverInfo = try await ManagementService.getInfo() + if let nativeInfo = try await nativeInfoRequest { + nativeServerConfirmed = true + serverInfo = nativeInfo + } else { + nativeServerConfirmed = false + serverInfo = try await ManagementService.getInfo() + } + updateCheck = try await updateCheckRequest } catch { ErrorManager.shared.alert(error: error) isLoading = false diff --git a/KMReader/Features/Server/ServerSection.swift b/KMReader/Features/Server/ServerSection.swift index 882ed7190..02576e0ab 100644 --- a/KMReader/Features/Server/ServerSection.swift +++ b/KMReader/Features/Server/ServerSection.swift @@ -16,6 +16,7 @@ enum ServerSection: String, CaseIterable { case servers case account case apiKeys + case accountSessions case authenticationActivity var icon: String { @@ -37,6 +38,8 @@ enum ServerSection: String, CaseIterable { return "person.crop.circle" case .apiKeys: return "key" + case .accountSessions: + return "person.2" case .authenticationActivity: return "person.badge.key" @@ -62,6 +65,8 @@ enum ServerSection: String, CaseIterable { return .green case .apiKeys: return .yellow + case .accountSessions: + return .blue case .authenticationActivity: return .gray } @@ -86,6 +91,8 @@ enum ServerSection: String, CaseIterable { return String(localized: "Account") case .apiKeys: return String(localized: "API Keys") + case .accountSessions: + return String(localized: "Sessions") case .authenticationActivity: return String(localized: "Authentication Activity") } diff --git a/KMReader/Features/Server/ServerSessionRow.swift b/KMReader/Features/Server/ServerSessionRow.swift new file mode 100644 index 000000000..e612e13c2 --- /dev/null +++ b/KMReader/Features/Server/ServerSessionRow.swift @@ -0,0 +1,80 @@ +// +// ServerSessionRow.swift +// + +import SwiftUI + +struct ServerSessionRow: View { + let session: ServerSession + let isKicking: Bool + let canKick: Bool + let onKick: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(session.id) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .textSelectionIfAvailable() + Spacer() + if session.isCurrent { + Text(String(localized: "Current")) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(session.expired ? String(localized: "Expired") : String(localized: "Active")) + .font(.caption) + .foregroundStyle(session.expired ? .red : .green) + } + } + if let email = session.email { + Text(email) + .font(.caption) + .foregroundStyle(.secondary) + } else if let userId = session.userId { + Text(userId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + HStack { + if let createdAt = session.createdAt { + Text( + String.localizedStringWithFormat( + String(localized: "session.created", defaultValue: "Created %@"), + createdAt.formatted(.relative(presentation: .named)) + ) + ) + } + if let lastAccessedAt = session.lastAccessedAt { + Text("·") + Text( + String.localizedStringWithFormat( + String(localized: "session.lastActive", defaultValue: "Active %@"), + lastAccessedAt.formatted(.relative(presentation: .named)) + ) + ) + } + Spacer() + if !session.isCurrent { + Button(role: .destructive, action: onKick) { + Group { + if isKicking { + ProgressView() + } else { + Text(String(localized: "Kick")) + } + } + .contentShape(Rectangle()) + } + .disabled(!canKick) + } + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 2) + .tvFocusableHighlight() + } +} diff --git a/KMReader/Features/Server/ServerSessionsView.swift b/KMReader/Features/Server/ServerSessionsView.swift index e26d1eb65..e7d446a85 100644 --- a/KMReader/Features/Server/ServerSessionsView.swift +++ b/KMReader/Features/Server/ServerSessionsView.swift @@ -1,21 +1,24 @@ // // ServerSessionsView.swift // -// import SwiftUI -/// Active server sessions from `/actuator/sessions` (admin only), most -/// recently active first, with per-session kick. kmrs lists every session; -/// Komga only answers the per-username variant, so the list falls back to -/// the current user's own sessions there. struct ServerSessionsView: View { - @AppStorage("currentAccount") private var current: Current = .init() + let scope: ServerSessionScope - @State private var sessions: [ActuatorSessionsResponse.Session] = [] + @AppStorage("currentAccount") private var current: Current = .init() + @State private var pagination = PaginationState(pageSize: 20) @State private var isLoading = false @State private var kickingSessionId: String? @State private var hasLoaded = false + @State private var nativeUnsupported = false + @State private var usesNativeAPI = false + @State private var loadedInstanceId: String? + + init(scope: ServerSessionScope = .all) { + self.scope = scope + } var body: some View { Form { @@ -26,7 +29,15 @@ struct ServerSessionsView: View { ProgressView() Spacer() } - } else if sessions.isEmpty { + } else if showsUnavailable { + ContentUnavailableView { + Label(String(localized: "Sessions Unavailable"), systemImage: "person.2") + } description: { + Text(String(localized: "This server does not support managing your sessions.")) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } else if pagination.isEmpty { ContentUnavailableView { Label(String(localized: "No Sessions"), systemImage: "person.2") } description: { @@ -35,68 +46,28 @@ struct ServerSessionsView: View { .frame(maxWidth: .infinity) .padding(.vertical, 16) } else { - ForEach(sessions) { session in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(session.id) - .font(.caption.monospaced()) - .lineLimit(1) - .truncationMode(.middle) - .textSelectionIfAvailable() - Spacer() - if session.expired { - Text(String(localized: "Expired")) - .font(.caption) - .foregroundColor(.red) - } else { - Text(String(localized: "Active")) - .font(.caption) - .foregroundColor(.green) - } - } - - HStack { - if let createdAt = session.createdAt { - Text( - String.localizedStringWithFormat( - String(localized: "session.created", defaultValue: "Created %@"), - createdAt.formatted(.relative(presentation: .named)) - ) - ) - } - if let lastAccessedAt = session.lastAccessedAt { - Text("·") - Text( - String.localizedStringWithFormat( - String(localized: "session.lastActive", defaultValue: "Active %@"), - lastAccessedAt.formatted(.relative(presentation: .named)) - ) - ) - } - Spacer() - Button(role: .destructive) { - kick(session) - } label: { - Group { - if kickingSessionId == session.id { - ProgressView() - } else { - Text(String(localized: "Kick")) - } - } - .contentShape(Rectangle()) - } - .disabled(kickingSessionId != nil) - } - .font(.caption) - .foregroundColor(.secondary) + ForEach(pagination.items) { session in + ServerSessionRow( + session: session, + isKicking: kickingSessionId == session.id, + canKick: kickingSessionId == nil && !session.isCurrent + ) { + kick(session) + } + .onAppear { + loadMoreIfNeeded(session) + } + } + if isLoading && hasLoaded { + HStack { + Spacer() + ProgressView() + Spacer() } - .padding(.vertical, 2) - .tvFocusableHighlight() } } } footer: { - if !sessions.isEmpty { + if !pagination.isEmpty { Text(String(localized: "Kicking a session signs that device out immediately.")) } } @@ -104,57 +75,97 @@ struct ServerSessionsView: View { .formStyle(.grouped) .platformNavigationTitle(String(localized: "Sessions")) .task { - await loadSessions() + guard loadedInstanceId != current.instanceId else { return } + loadedInstanceId = current.instanceId + await loadSessions(refresh: true) } .refreshable { - await loadSessions() + await loadSessions(refresh: true) } } - private func loadSessions() async { + private var showsUnavailable: Bool { + nativeUnsupported && scope == .own + } + + private func loadSessions(refresh: Bool) async { + if refresh { + pagination.reset() + hasLoaded = false + } + guard !isLoading else { return } isLoading = true + defer { isLoading = false } + do { - let response = try await ManagementService.getSessions() - apply(response) - } catch let error as APIError { - // Komga rejects the cross-user listing (400/404); retry per-user. - if case .notFound = error, !current.username.isEmpty { - await loadOwnSessions() - } else if case .badRequest = error, !current.username.isEmpty { - await loadOwnSessions() + if let page = try await ManagementService.getNativeSessions( + scope: scope, page: pagination.currentPage, size: pagination.pageSize) + { + usesNativeAPI = true + nativeUnsupported = false + apply(page) + } else if scope == .all { + usesNativeAPI = false + try await loadActuatorSessions() } else { - ErrorManager.shared.alert(error: error) + nativeUnsupported = true } + hasLoaded = true } catch { ErrorManager.shared.alert(error: error) } - isLoading = false } - private func loadOwnSessions() async { + private func loadActuatorSessions() async throws { do { - let response = try await ManagementService.getSessions(username: current.username) - apply(response) - } catch { - ErrorManager.shared.alert(error: error) + let response = try await ManagementService.getSessions() + apply(response.sessions.map(\.serverSession), hasMore: false) + } catch let error as APIError { + guard !current.username.isEmpty else { throw error } + if case .notFound = error { + try await loadOwnActuatorSessions() + } else if case .badRequest = error { + try await loadOwnActuatorSessions() + } else { + throw error + } } } - private func apply(_ response: ActuatorSessionsResponse) { - sessions = response.sessions.sorted { - ($0.lastAccessedAt ?? .distantPast) > ($1.lastAccessedAt ?? .distantPast) + private func loadOwnActuatorSessions() async throws { + let response = try await ManagementService.getSessions(username: current.username) + apply(response.sessions.map(\.serverSession), hasMore: false) + } + + private func apply(_ page: Page) { + _ = pagination.applyPage(page.content) + pagination.advance(moreAvailable: !page.last) + } + + private func apply(_ newSessions: [ServerSession], hasMore: Bool) { + _ = pagination.applyPage(newSessions) + pagination.advance(moreAvailable: hasMore) + } + + private func loadMoreIfNeeded(_ session: ServerSession) { + guard usesNativeAPI, pagination.shouldLoadMore(after: session), !isLoading else { return } + Task { + await loadSessions(refresh: false) } - hasLoaded = true } - private func kick(_ session: ActuatorSessionsResponse.Session) { - guard kickingSessionId == nil else { return } + private func kick(_ session: ServerSession) { + guard kickingSessionId == nil, !session.isCurrent else { return } kickingSessionId = session.id Task { do { - try await ManagementService.deleteSession(id: session.id) + if usesNativeAPI { + try await ManagementService.deleteNativeSession(id: session.id, scope: scope) + } else { + try await ManagementService.deleteSession(id: session.id) + } ErrorManager.shared.notify(message: String(localized: "notification.session.kicked")) - await loadSessions() + await loadSessions(refresh: true) } catch { ErrorManager.shared.alert(error: error) } diff --git a/KMReader/Features/Server/ServerUpdateCheckRow.swift b/KMReader/Features/Server/ServerUpdateCheckRow.swift new file mode 100644 index 000000000..46249ad0f --- /dev/null +++ b/KMReader/Features/Server/ServerUpdateCheckRow.swift @@ -0,0 +1,63 @@ +// +// ServerUpdateCheckRow.swift +// + +import SwiftUI + +struct ServerUpdateCheckRow: View { + let check: ServerUpdateCheck + + var body: some View { + if let update = check.update { + if let url = update.releaseURL { + Link(destination: url) { + label( + title: String(localized: "Update Available"), + value: update.version, + icon: "arrow.down.circle" + ) + .contentShape(Rectangle()) + } + } else { + label( + title: String(localized: "Update Available"), + value: update.version, + icon: "arrow.down.circle" + ) + } + } else if !check.enabled { + label( + title: String(localized: "Update Checks"), + value: String(localized: "Disabled"), + icon: "checkmark.circle" + ) + } else if check.checkedAt == nil { + label( + title: String(localized: "Update Checks"), + value: String(localized: "Not checked yet"), + icon: "clock" + ) + } else { + label( + title: String(localized: "Server Version"), + value: String(localized: "Up to date"), + icon: "checkmark.circle" + ) + } + if let checkedAt = check.checkedAt { + LabeledContent { + Text(checkedAt.formatted(.relative(presentation: .named))) + } label: { + Label(String(localized: "Last Checked"), systemImage: "calendar") + } + } + } + + private func label(title: String, value: String, icon: String) -> some View { + LabeledContent { + Text(value) + } label: { + Label(title, systemImage: icon) + } + } +} diff --git a/KMReader/Features/Server/ServerView.swift b/KMReader/Features/Server/ServerView.swift index 02fb7fb2f..970de11f9 100644 --- a/KMReader/Features/Server/ServerView.swift +++ b/KMReader/Features/Server/ServerView.swift @@ -133,6 +133,15 @@ struct ServerView: View { } .adaptiveButtonStyle(.plain) + NavigationLink(value: NavDestination.settingsAccountSessions) { + ServerActionTile( + title: ServerSection.accountSessions.title, + systemImage: ServerSection.accountSessions.icon, + color: ServerSection.accountSessions.color + ) + } + .adaptiveButtonStyle(.plain) + NavigationLink(value: NavDestination.settingsAuthenticationActivity) { ServerActionTile( title: ServerSection.authenticationActivity.title, diff --git a/KMReader/Features/Settings/SettingsAccountView.swift b/KMReader/Features/Settings/SettingsAccountView.swift index 758a129be..2304e2838 100644 --- a/KMReader/Features/Settings/SettingsAccountView.swift +++ b/KMReader/Features/Settings/SettingsAccountView.swift @@ -77,6 +77,13 @@ struct SettingsAccountView: View { color: ServerSection.apiKeys.color ) } + NavigationLink(value: NavDestination.settingsAccountSessions) { + SettingsBadgeRow( + title: ServerSection.accountSessions.title, + icon: ServerSection.accountSessions.icon, + color: ServerSection.accountSessions.color + ) + } NavigationLink(value: NavDestination.settingsAuthenticationActivity) { SettingsBadgeRow( title: ServerSection.authenticationActivity.title, diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index ddbfc6edd..2b135d114 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -16526,6 +16526,70 @@ } } }, + "Current" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktuell" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actuelle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attuale" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "現在" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Текущий" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目前" + } + } + } + }, "Current Book" : { "localizations" : { "de" : { @@ -39452,6 +39516,70 @@ } } }, + "Last Checked" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt geprüft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last Checked" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Última comprobación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dernière vérification" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ultimo controllo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最終確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 확인" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Последняя проверка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次检查" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "上次檢查" + } + } + } + }, "Last page" : { "localizations" : { "de" : { @@ -52508,6 +52636,70 @@ } } }, + "Not checked yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch nicht geprüft" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not checked yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún no comprobado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas encore vérifié" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non ancora controllato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "まだ確認していません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 확인하지 않음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ещё не проверялось" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未检查" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "尚未檢查" + } + } + } + }, "Not Configured" : { "localizations" : { "de" : { @@ -76190,6 +76382,70 @@ } } }, + "Server Version" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serverversion" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server Version" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versión del servidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version du serveur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versione del server" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サーバーバージョン" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 버전" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Версия сервера" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器版本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器版本" + } + } + } + }, "server.info.diskFree" : { "localizations" : { "de" : { @@ -76830,6 +77086,70 @@ } } }, + "Sessions Unavailable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sitzungen nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions Unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesiones no disponibles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions indisponibles" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessioni non disponibili" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "セッションを利用できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "세션을 사용할 수 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессии недоступны" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "会话不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作階段不可用" + } + } + } + }, "Settings" : { "localizations" : { "de" : { @@ -90530,6 +90850,70 @@ } } }, + "This server does not support managing your sessions." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Server unterstützt die Verwaltung deiner Sitzungen nicht." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This server does not support managing your sessions." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este servidor no permite gestionar tus sesiones." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce serveur ne permet pas de gérer vos sessions." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo server non supporta la gestione delle tue sessioni." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このサーバーではセッションを管理できません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 서버에서는 세션을 관리할 수 없습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот сервер не поддерживает управление вашими сессиями." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此服务器不支持管理你的会话。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此伺服器不支援管理你的工作階段。" + } + } + } + }, "This server has not been initialized yet. Create the first administrator account to get started." : { "localizations" : { "de" : { @@ -93794,6 +94178,262 @@ } } }, + "Up to date" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf dem neuesten Stand" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Up to date" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "最新" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최신" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Актуальная версия" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已是最新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "已是最新" + } + } + } + }, + "Update Available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Available" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualización disponible" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour disponible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデートがあります" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 사용 가능" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Доступно обновление" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有可用更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有可用更新" + } + } + } + }, + "Update Checks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updateprüfungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Update Checks" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comprobaciones de actualización" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vérifications de mise à jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Controlli aggiornamenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデート確認" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트 확인" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверки обновлений" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新检查" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新檢查" + } + } + } + }, + "Updates" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizaciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises à jour" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamenti" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "アップデート" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "업데이트" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обновления" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + } + } + }, "Uptime" : { "localizations" : { "de" : { diff --git a/KMReader/Shared/Foundation/Models/NavDestination.swift b/KMReader/Shared/Foundation/Models/NavDestination.swift index 27fd124fa..7f9488026 100644 --- a/KMReader/Shared/Foundation/Models/NavDestination.swift +++ b/KMReader/Shared/Foundation/Models/NavDestination.swift @@ -81,6 +81,7 @@ enum NavDestination: Hashable { case settingsServers case settingsApiKey + case settingsAccountSessions case settingsAuthenticationActivity case settingsAccount case settingsAbout @@ -287,6 +288,8 @@ enum NavDestination: Hashable { ServerListView(authViewModel: context.authViewModel) case .settingsApiKey: ApiKeysView() + case .settingsAccountSessions: + ServerSessionsView(scope: .own) case .settingsAuthenticationActivity: AccountActivityView() case .settingsAccount: