Add complete OAuth 2.0 Authorization Code Flow with PKCE as alternative to API token authentication, with automatic server detection and graceful fallback to classic login. **OAuth Core (RFC 7636 PKCE):** - PKCEGenerator: S256 challenge generation for secure code exchange - OAuth DTOs: Client registration, token request/response models - OAuthClient, OAuthToken, AuthenticationMethod domain models - API.swift: registerOAuthClient() and exchangeOAuthToken() endpoints - OAuthRepository + POAuthRepository protocol **Browser Integration (ASWebAuthenticationSession):** - OAuthSession: Wraps native authentication session - OAuthFlowCoordinator: Orchestrates 5-phase OAuth flow - readeck:// URL scheme for OAuth callback handling - State verification for CSRF protection - User cancellation handling **Token Management:** - KeychainHelper: OAuth token storage alongside API tokens - TokenProvider: getOAuthToken(), setOAuthToken(), getAuthMethod() - AuthenticationMethod enum to distinguish token types - AuthRepository: loginWithOAuth(), getAuthenticationMethod() - Endpoint persistence in both Keychain and Settings **Server Feature Detection:** - ServerInfo extended with features array and supportsOAuth flag - GET /api/info endpoint integration (backward compatible) - GetServerInfoUseCase with optional endpoint parameter **User Profile Integration:** - ProfileApiClient: Fetch user data via GET /api/profile - UserProfileDto with username, email, provider information - GetUserProfileUseCase: Extract username from profile - Username saved and displayed for OAuth users (like classic auth) **Automatic OAuth Flow (No User Selection):** - OnboardingServerView: 2-phase flow (endpoint → auto-OAuth or classic) - OAuth attempted automatically if server supports it - Fallback to username/password on OAuth failure or unsupported - SettingsServerViewModel: checkServerOAuthSupport(), loginWithOAuth() **Cleanup & Refactoring:** - Remove all #if os(iOS) && !APP_EXTENSION conditionals - Remove LoginMethodSelectionView (no longer needed) - Remove switchToClassicLogin() method - Factories updated with OAuth dependencies **Testing:** - PKCEGeneratorTests: Verify RFC 7636 compliance - ServerInfoTests: Feature detection and backward compatibility - Mock implementations for all OAuth components **Documentation:** - docs/OAuth2-Implementation-Plan.md: Complete implementation guide - openapi.json: Readeck API specification **Scopes Requested:** - bookmarks:read, bookmarks:write, profile:read OAuth users now have full feature parity with classic authentication. Server auto-detects OAuth support via /info endpoint. Seamless UX with browser-based login and automatic fallback.
403 lines
14 KiB
Swift
403 lines
14 KiB
Swift
//
|
|
// DebugMenuView.swift
|
|
// readeck
|
|
//
|
|
// Created by Ilyas Hallak on 21.11.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import netfox
|
|
|
|
struct DebugMenuView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@EnvironmentObject private var appSettings: AppSettings
|
|
@StateObject private var viewModel = DebugMenuViewModel()
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
List {
|
|
// MARK: - Network Section
|
|
Section {
|
|
networkSimulationToggle
|
|
networkStatusInfo
|
|
|
|
Button {
|
|
NFX.sharedInstance().show()
|
|
} label: {
|
|
Label("Show NetFox", systemImage: "network")
|
|
.foregroundColor(.blue)
|
|
}
|
|
|
|
HStack {
|
|
Text("NetFox Status")
|
|
Spacer()
|
|
Text(viewModel.isNetFoxRunning ? "Running" : "Stopped")
|
|
.font(.caption)
|
|
.foregroundColor(viewModel.isNetFoxRunning ? .green : .secondary)
|
|
}
|
|
} header: {
|
|
Text("Network Debugging")
|
|
} footer: {
|
|
Text("Simulate offline mode and monitor network requests with NetFox")
|
|
}
|
|
|
|
// MARK: - Logging Section
|
|
Section {
|
|
Toggle("Enable Logging", isOn: $viewModel.isLoggingEnabled)
|
|
.tint(.green)
|
|
.onChange(of: viewModel.isLoggingEnabled) { _, newValue in
|
|
viewModel.updateLoggingStatus(enabled: newValue)
|
|
}
|
|
|
|
if viewModel.isLoggingEnabled {
|
|
NavigationLink {
|
|
DebugLogViewer()
|
|
} label: {
|
|
Label("Debug Logs", systemImage: "doc.text.magnifyingglass")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
viewModel.clearLogs()
|
|
} label: {
|
|
Label("Clear All Logs", systemImage: "trash")
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Logging")
|
|
} footer: {
|
|
if viewModel.isLoggingEnabled {
|
|
Text("View and manage application logs")
|
|
} else {
|
|
Text("Enable logging to capture debug messages")
|
|
}
|
|
}
|
|
|
|
// MARK: - Offline Debugging Section
|
|
Section {
|
|
Picker("Select Cached Bookmark", selection: $viewModel.selectedBookmarkId) {
|
|
Text("None").tag(nil as String?)
|
|
ForEach(viewModel.cachedBookmarks, id: \.id) { bookmark in
|
|
Text(bookmark.title.isEmpty ? bookmark.id : bookmark.title)
|
|
.lineLimit(1)
|
|
.tag(bookmark.id as String?)
|
|
}
|
|
}
|
|
|
|
NavigationLink {
|
|
OfflineImageDebugView(bookmarkId: viewModel.selectedBookmarkId ?? "")
|
|
} label: {
|
|
Label("Offline Image Diagnostics", systemImage: "photo.badge.checkmark")
|
|
}
|
|
.disabled(viewModel.selectedBookmarkId == nil)
|
|
} header: {
|
|
Text("Offline Reading")
|
|
} footer: {
|
|
Text("Select a cached bookmark to diagnose offline image issues")
|
|
}
|
|
|
|
// MARK: - Data Section
|
|
Section {
|
|
cacheInfo
|
|
|
|
Button(role: .destructive) {
|
|
viewModel.showResetCacheAlert = true
|
|
} label: {
|
|
Label("Clear Offline Cache", systemImage: "trash")
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
viewModel.showResetDatabaseAlert = true
|
|
} label: {
|
|
Label("Reset Core Data", systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
}
|
|
} header: {
|
|
Text("Data Management")
|
|
} footer: {
|
|
Text("⚠️ Reset Core Data will delete all local bookmarks and cache")
|
|
}
|
|
|
|
// MARK: - Advanced Section
|
|
Section {
|
|
NavigationLink {
|
|
LoggingConfigurationView()
|
|
} label: {
|
|
Label("Logging Configuration", systemImage: "slider.horizontal.3")
|
|
}
|
|
|
|
NavigationLink {
|
|
FontDebugView()
|
|
} label: {
|
|
Label("Font Debug", systemImage: "textformat")
|
|
}
|
|
} header: {
|
|
Text("Advanced")
|
|
}
|
|
|
|
// MARK: - App Info Section
|
|
Section {
|
|
HStack {
|
|
Text("App Version")
|
|
Spacer()
|
|
Text(viewModel.appVersion)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Build Number")
|
|
Spacer()
|
|
Text(viewModel.buildNumber)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Bundle ID")
|
|
Spacer()
|
|
Text(viewModel.bundleId)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Build Type")
|
|
Spacer()
|
|
Text(viewModel.buildType)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} header: {
|
|
Text("App Information")
|
|
}
|
|
}
|
|
.navigationTitle("🛠️ Debug Menu")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.loadCacheInfo()
|
|
viewModel.checkNetFoxStatus()
|
|
}
|
|
.alert("Clear Offline Cache?", isPresented: $viewModel.showResetCacheAlert) {
|
|
Button("Cancel", role: .cancel) { }
|
|
Button("Clear", role: .destructive) {
|
|
Task {
|
|
await viewModel.clearOfflineCache()
|
|
}
|
|
}
|
|
} message: {
|
|
Text("This will remove all cached articles. Your bookmarks will remain.")
|
|
}
|
|
.alert("Reset Core Data?", isPresented: $viewModel.showResetDatabaseAlert) {
|
|
Button("Cancel", role: .cancel) { }
|
|
Button("Reset", role: .destructive) {
|
|
viewModel.resetCoreData()
|
|
}
|
|
} message: {
|
|
Text("⚠️ WARNING: This will delete ALL local data including bookmarks, cache, and settings. This cannot be undone!")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews
|
|
|
|
private var networkSimulationToggle: some View {
|
|
Toggle(isOn: Binding(
|
|
get: { !appSettings.isNetworkConnected },
|
|
set: { isOffline in
|
|
appSettings.isNetworkConnected = !isOffline
|
|
}
|
|
)) {
|
|
HStack {
|
|
Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash")
|
|
.foregroundColor(appSettings.isNetworkConnected ? .green : .orange)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Simulate Offline Mode")
|
|
Text(appSettings.isNetworkConnected ? "Network Connected" : "Network Disconnected")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var networkStatusInfo: some View {
|
|
HStack {
|
|
Text("Network Status")
|
|
Spacer()
|
|
Label(
|
|
appSettings.isNetworkConnected ? "Connected" : "Offline",
|
|
systemImage: appSettings.isNetworkConnected ? "checkmark.circle.fill" : "xmark.circle.fill"
|
|
)
|
|
.font(.caption)
|
|
.foregroundColor(appSettings.isNetworkConnected ? .green : .orange)
|
|
}
|
|
}
|
|
|
|
private var cacheInfo: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Cached Articles")
|
|
Spacer()
|
|
Text("\(viewModel.cachedArticlesCount)")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
HStack {
|
|
Text("Cache Size")
|
|
Spacer()
|
|
Text(viewModel.cacheSize)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.loadCacheInfo()
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
class DebugMenuViewModel: ObservableObject {
|
|
@Published var showResetCacheAlert = false
|
|
@Published var showResetDatabaseAlert = false
|
|
@Published var cachedArticlesCount = 0
|
|
@Published var cacheSize = "0 KB"
|
|
@Published var selectedBookmarkId: String?
|
|
@Published var cachedBookmarks: [Bookmark] = []
|
|
@Published var isLoggingEnabled = false
|
|
@Published var isNetFoxRunning = false
|
|
|
|
private let offlineCacheRepository = OfflineCacheRepository()
|
|
private let coreDataManager = CoreDataManager.shared
|
|
private let logger = Logger.general
|
|
private let logConfig = LogConfiguration.shared
|
|
|
|
var appVersion: String {
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
|
}
|
|
|
|
var buildNumber: String {
|
|
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown"
|
|
}
|
|
|
|
var bundleId: String {
|
|
Bundle.main.bundleIdentifier ?? "Unknown"
|
|
}
|
|
|
|
var buildType: String {
|
|
if Bundle.main.isDebugBuild {
|
|
return "Debug"
|
|
} else if Bundle.main.isTestFlightBuild {
|
|
return "TestFlight"
|
|
} else if Bundle.main.isProduction {
|
|
return "Production"
|
|
} else {
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
init() {
|
|
isLoggingEnabled = logConfig.isLoggingEnabled
|
|
}
|
|
|
|
func checkNetFoxStatus() {
|
|
// NetFox doesn't provide a direct API to check if it's running
|
|
// We'll just assume it's running if we're in a non-production build
|
|
isNetFoxRunning = !Bundle.main.isProduction
|
|
}
|
|
|
|
func updateLoggingStatus(enabled: Bool) {
|
|
logConfig.isLoggingEnabled = enabled
|
|
logger.info("Logging \(enabled ? "enabled" : "disabled") via Debug Menu")
|
|
}
|
|
|
|
func loadCacheInfo() async {
|
|
cachedArticlesCount = offlineCacheRepository.getCachedArticlesCount()
|
|
cacheSize = offlineCacheRepository.getCacheSize()
|
|
|
|
// Load cached bookmarks for diagnostics
|
|
do {
|
|
cachedBookmarks = try await offlineCacheRepository.getCachedBookmarks()
|
|
// Auto-select first bookmark if available
|
|
if selectedBookmarkId == nil, let firstBookmark = cachedBookmarks.first {
|
|
selectedBookmarkId = firstBookmark.id
|
|
}
|
|
} catch {
|
|
logger.error("Failed to load cached bookmarks: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func clearOfflineCache() async {
|
|
do {
|
|
try await offlineCacheRepository.clearCache()
|
|
await loadCacheInfo()
|
|
logger.info("Offline cache cleared via Debug Menu")
|
|
} catch {
|
|
logger.error("Failed to clear offline cache: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func clearLogs() {
|
|
Task {
|
|
await LogStore.shared.clear()
|
|
logger.info("Logs cleared via Debug Menu")
|
|
}
|
|
}
|
|
|
|
func resetCoreData() {
|
|
do {
|
|
try coreDataManager.resetDatabase()
|
|
logger.warning("Core Data reset via Debug Menu - App restart required")
|
|
|
|
// Show alert that restart is needed
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
fatalError("Core Data has been reset. Please restart the app.")
|
|
}
|
|
} catch {
|
|
logger.error("Failed to reset Core Data: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Shake Gesture Detection
|
|
|
|
extension UIDevice {
|
|
static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification")
|
|
}
|
|
|
|
extension UIWindow {
|
|
open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
|
|
if motion == .motionShake {
|
|
NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DeviceShakeViewModifier: ViewModifier {
|
|
let action: () -> Void
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onAppear()
|
|
.onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in
|
|
action()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func onShake(perform action: @escaping () -> Void) -> some View {
|
|
self.modifier(DeviceShakeViewModifier(action: action))
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
DebugMenuView()
|
|
.environmentObject(AppSettings())
|
|
}
|