Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/repo-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LibraryBrowseScope>` 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
Expand Down
28 changes: 28 additions & 0 deletions KMReader/Core/Storage/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
87 changes: 87 additions & 0 deletions KMReader/Core/Storage/ManagementService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -73,6 +112,54 @@ nonisolated enum ManagementService {
)
}

static func getNativeSessions(scope: ServerSessionScope, page: Int, size: Int) async throws
-> Page<ServerSession>?
{
guard shouldQueryNativeSessions else { return nil }
let path = scope == .own ? "/api/v2/users/me/sessions" : "/api/v2/users/sessions"
do {
let page: Page<NativeServerSession> = 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")
Expand Down
19 changes: 19 additions & 0 deletions KMReader/Features/Server/Models/NativeServerInfo.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
25 changes: 25 additions & 0 deletions KMReader/Features/Server/Models/NativeServerSession.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
50 changes: 50 additions & 0 deletions KMReader/Features/Server/Models/ServerCapability.swift
Original file line number Diff line number Diff line change
@@ -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)) ?? [:])
}
}
62 changes: 62 additions & 0 deletions KMReader/Features/Server/Models/ServerSession.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
10 changes: 10 additions & 0 deletions KMReader/Features/Server/Models/ServerSessionScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// ServerSessionScope.swift
//

import Foundation

nonisolated enum ServerSessionScope: Sendable {
case own
case all
}
Loading