feat: Implement OAuth 2.0 authentication with PKCE and automatic fallback
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.
This commit is contained in:
parent
03cd32dd4d
commit
ec432a037c
470
docs/OAuth2-Implementation-Plan.md
Normal file
470
docs/OAuth2-Implementation-Plan.md
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
# OAuth 2.0 Authentication - Implementation Plan
|
||||||
|
|
||||||
|
**Date:** December 19, 2025
|
||||||
|
**Status:** In Progress (Phase 1-4 Complete ✅)
|
||||||
|
**Goal:** Add OAuth 2.0 authentication support as an alternative to API token authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
### Current Situation (Updated)
|
||||||
|
|
||||||
|
- ✅ **Phase 1-4 Complete:** ServerInfo extended, OAuth core implemented, browser integration done, token management working
|
||||||
|
- ✅ **Direct OAuth Flow:** No login method selection - OAuth is tried automatically if server supports it
|
||||||
|
- ✅ **Fallback to Classic:** If OAuth fails or is not supported, classic username/password login is shown
|
||||||
|
- ✅ **Endpoint Storage:** Endpoint is now saved in both TokenProvider and Settings
|
||||||
|
- ❌ **API Integration:** API calls don't yet use OAuth tokens properly
|
||||||
|
- ❌ **Testing:** Manual and integration testing not complete
|
||||||
|
|
||||||
|
### What's Implemented ✅
|
||||||
|
|
||||||
|
**Phase 1: ServerInfo Extended**
|
||||||
|
- ✅ `ServerInfoDto` with nested `VersionInfo` struct (canonical, release, build)
|
||||||
|
- ✅ `ServerInfo` model with `features: [String]?` and `supportsOAuth` computed property
|
||||||
|
- ✅ `GetServerInfoUseCase` with optional endpoint parameter
|
||||||
|
- ✅ `InfoApiClient` with endpoint parameter logic (custom endpoint vs TokenProvider)
|
||||||
|
- ✅ Unit tests for ServerInfo and feature detection
|
||||||
|
- ✅ Backward compatibility with old servers
|
||||||
|
|
||||||
|
**Phase 2: OAuth Core**
|
||||||
|
- ✅ `PKCEGenerator` with verifier/challenge generation
|
||||||
|
- ✅ OAuth DTOs: `OAuthClientCreateDto`, `OAuthClientResponseDto`, `OAuthTokenRequestDto`, `OAuthTokenResponseDto`
|
||||||
|
- ✅ Domain models: `OAuthClient`, `OAuthToken`, `AuthenticationMethod`
|
||||||
|
- ✅ `API.swift` extended with OAuth methods (`registerOAuthClient`, `exchangeOAuthToken`)
|
||||||
|
- ✅ `OAuthRepository` + `POAuthRepository` protocol
|
||||||
|
- ✅ `OAuthManager` orchestrates OAuth flow
|
||||||
|
- ✅ Unit tests for PKCEGenerator
|
||||||
|
|
||||||
|
**Phase 3: Browser Integration**
|
||||||
|
- ✅ `OAuthSession` wraps `ASWebAuthenticationSession`
|
||||||
|
- ✅ `OAuthFlowCoordinator` manages 5-phase OAuth flow
|
||||||
|
- ✅ `readeck://` URL scheme registered in Info.plist
|
||||||
|
- ✅ State verification for CSRF protection
|
||||||
|
- ✅ Error handling for user cancellation
|
||||||
|
|
||||||
|
**Phase 4: Token Management**
|
||||||
|
- ✅ `AuthenticationMethod` enum (apiToken, oauth)
|
||||||
|
- ✅ `TokenProvider` extended with OAuth methods (`getOAuthToken`, `setOAuthToken`, `getAuthMethod`, `setAuthMethod`)
|
||||||
|
- ✅ `setEndpoint(_ endpoint: String)` added to TokenProvider
|
||||||
|
- ✅ `KeychainHelper` extended with OAuth token storage
|
||||||
|
- ✅ `KeychainTokenProvider` handles both token types
|
||||||
|
- ✅ `AuthRepository` extended with `loginWithOAuth`, `getAuthenticationMethod`, `switchToClassicAuth`
|
||||||
|
- ✅ Endpoint now saved in TokenProvider (was missing before)
|
||||||
|
|
||||||
|
**Phase 5: UI & UX (Partially Complete)**
|
||||||
|
- ✅ `OnboardingServerView` refactored with 2-phase flow:
|
||||||
|
- Phase 1: Only endpoint field + Readeck logo
|
||||||
|
- Phase 2a: OAuth attempted automatically if server supports it
|
||||||
|
- Phase 2b: Username/Password fields shown if OAuth not supported or fails
|
||||||
|
- ✅ `SettingsServerViewModel` extended with `checkServerOAuthSupport()` and `loginWithOAuth()`
|
||||||
|
- ✅ `LoginWithOAuthUseCase` created
|
||||||
|
- ✅ Factories updated (DefaultUseCaseFactory, MockUseCaseFactory)
|
||||||
|
- ❌ LoginMethodSelectionView removed (no longer needed - OAuth is automatic)
|
||||||
|
|
||||||
|
**Removed/Changed:**
|
||||||
|
- ❌ All `#if os(iOS) && !APP_EXTENSION` checks removed
|
||||||
|
- ❌ `LoginMethodSelectionView` removed - OAuth is now attempted automatically
|
||||||
|
- ❌ `showLoginMethodSelection` flag removed
|
||||||
|
- ✅ Direct OAuth attempt with fallback to classic login on error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Updated Goals
|
||||||
|
|
||||||
|
1. ~~**User Choice**~~ **Automatic OAuth**: OAuth is attempted automatically if server supports it
|
||||||
|
2. **Auto-Detection**: ✅ Automatically detect if server supports OAuth via `/info` endpoint
|
||||||
|
3. **Security**: ✅ OAuth 2.0 Authorization Code Flow with PKCE (S256)
|
||||||
|
4. **UX**: ✅ Seamless browser-based authentication using `ASWebAuthenticationSession`
|
||||||
|
5. **Fallback**: ✅ Graceful fallback to username/password if OAuth fails or is not supported
|
||||||
|
6. **Migration Path**: ❌ Not yet implemented for existing users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 API Analysis
|
||||||
|
|
||||||
|
### Server Info Response (Actual Format)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": {
|
||||||
|
"canonical": "0.21.4",
|
||||||
|
"release": "0.21.4",
|
||||||
|
"build": ""
|
||||||
|
},
|
||||||
|
"features": ["oauth"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Changes from Original Plan:**
|
||||||
|
- `version` is now an object, not a string
|
||||||
|
- `buildDate` and `userAgent` removed (not in actual response)
|
||||||
|
- `features` is optional (only in newer servers)
|
||||||
|
|
||||||
|
### OAuth Endpoints
|
||||||
|
|
||||||
|
1. **Feature Detection**: `GET /api/info` (no auth)
|
||||||
|
2. **Client Registration**: `POST /api/oauth/client` (no auth)
|
||||||
|
3. **Authorization**: `GET /authorize` (browser, web page)
|
||||||
|
4. **Token Exchange**: `POST /api/oauth/token` (application/x-www-form-urlencoded)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture (Updated)
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Input → OnboardingServerView
|
||||||
|
↓
|
||||||
|
SettingsServerViewModel.checkServerOAuthSupport()
|
||||||
|
↓
|
||||||
|
GetServerInfoUseCase.execute(endpoint: normalizedEndpoint)
|
||||||
|
↓
|
||||||
|
ServerInfoRepository.getServerInfo(endpoint: normalizedEndpoint)
|
||||||
|
↓
|
||||||
|
InfoApiClient.getServerInfo(endpoint: normalizedEndpoint)
|
||||||
|
↓
|
||||||
|
GET {endpoint}/api/info (no auth token)
|
||||||
|
↓
|
||||||
|
Response: { version: {...}, features: ["oauth"] }
|
||||||
|
↓
|
||||||
|
serverSupportsOAuth = true
|
||||||
|
↓
|
||||||
|
SettingsServerViewModel.loginWithOAuth()
|
||||||
|
↓
|
||||||
|
LoginWithOAuthUseCase.execute(endpoint)
|
||||||
|
↓
|
||||||
|
OAuthFlowCoordinator.executeOAuthFlow(endpoint)
|
||||||
|
↓
|
||||||
|
[OAuth flow: client registration → browser → token exchange]
|
||||||
|
↓
|
||||||
|
AuthRepository.loginWithOAuth(endpoint, token)
|
||||||
|
↓
|
||||||
|
TokenProvider saves: OAuthToken, AuthMethod, Endpoint
|
||||||
|
↓
|
||||||
|
User logged in ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Components
|
||||||
|
|
||||||
|
**Domain Layer:**
|
||||||
|
- `ServerInfo` (version, features, supportsOAuth)
|
||||||
|
- `OAuthClient` (clientId, clientSecret, redirectUris, etc.)
|
||||||
|
- `OAuthToken` (accessToken, tokenType, scope, expiresIn, refreshToken, createdAt)
|
||||||
|
- `AuthenticationMethod` enum (apiToken, oauth)
|
||||||
|
|
||||||
|
**Data Layer:**
|
||||||
|
- `InfoApiClient` - handles `/api/info` with optional endpoint parameter
|
||||||
|
- `API` - extended with OAuth client registration and token exchange
|
||||||
|
- `OAuthRepository` - orchestrates OAuth API calls
|
||||||
|
- `OAuthManager` - business logic for OAuth flow
|
||||||
|
- `OAuthSession` - wraps ASWebAuthenticationSession
|
||||||
|
- `OAuthFlowCoordinator` - coordinates complete 5-phase flow
|
||||||
|
|
||||||
|
**Use Cases:**
|
||||||
|
- `GetServerInfoUseCase(endpoint: String?)` - get server info with optional custom endpoint
|
||||||
|
- `LoginWithOAuthUseCase` - execute OAuth login flow
|
||||||
|
- `CheckServerReachabilityUseCase` - check if server is reachable (existing)
|
||||||
|
|
||||||
|
**UI Layer:**
|
||||||
|
- `OnboardingServerView` - 2-phase onboarding (endpoint → OAuth or classic)
|
||||||
|
- `SettingsServerViewModel` - extended with OAuth support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 User Experience Flow (Updated)
|
||||||
|
|
||||||
|
### New User Onboarding
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User opens app
|
||||||
|
↓
|
||||||
|
2. Screen shows:
|
||||||
|
- Readeck logo (green circle)
|
||||||
|
- "Enter your Readeck server"
|
||||||
|
- Endpoint text field
|
||||||
|
- Chips: http://, https://, 192.168., :8000
|
||||||
|
- "Continue" button
|
||||||
|
↓
|
||||||
|
3. User enters endpoint (e.g., https://readeck.example.com)
|
||||||
|
↓
|
||||||
|
4. User taps "Continue"
|
||||||
|
↓
|
||||||
|
5. App normalizes endpoint and calls /api/info
|
||||||
|
↓
|
||||||
|
6a. If OAuth supported:
|
||||||
|
↓
|
||||||
|
App automatically starts OAuth flow
|
||||||
|
↓
|
||||||
|
Browser opens with /authorize page
|
||||||
|
↓
|
||||||
|
User logs in with username/password on server
|
||||||
|
↓
|
||||||
|
User approves permissions
|
||||||
|
↓
|
||||||
|
Redirect to readeck://oauth-callback?code=...
|
||||||
|
↓
|
||||||
|
App exchanges code for token
|
||||||
|
↓
|
||||||
|
Token + endpoint saved
|
||||||
|
↓
|
||||||
|
User logged in ✅
|
||||||
|
|
||||||
|
6b. If OAuth NOT supported OR OAuth fails:
|
||||||
|
↓
|
||||||
|
Username and Password fields appear
|
||||||
|
↓
|
||||||
|
Text changes to "Enter your credentials"
|
||||||
|
↓
|
||||||
|
Button changes to "Login & Save"
|
||||||
|
↓
|
||||||
|
User enters credentials
|
||||||
|
↓
|
||||||
|
Classic API token login
|
||||||
|
↓
|
||||||
|
User logged in ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key UX Changes:**
|
||||||
|
- No login method selection screen (removed)
|
||||||
|
- OAuth is attempted automatically
|
||||||
|
- Fallback to classic is seamless
|
||||||
|
- User sees endpoint field first, login fields only if needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Implementation Status
|
||||||
|
|
||||||
|
### ✅ Phase 1: ServerInfo Extended (COMPLETE)
|
||||||
|
- ✅ `ServerInfoDto` with `VersionInfo` nested struct
|
||||||
|
- ✅ `ServerInfo.features` optional array
|
||||||
|
- ✅ `supportsOAuth` computed property
|
||||||
|
- ✅ `GetServerInfoUseCase` with endpoint parameter
|
||||||
|
- ✅ Backward compatibility tests
|
||||||
|
- ✅ Mock implementations updated
|
||||||
|
|
||||||
|
### ✅ Phase 2: OAuth Core (COMPLETE)
|
||||||
|
- ✅ PKCEGenerator (verifier + challenge)
|
||||||
|
- ✅ All OAuth DTOs created
|
||||||
|
- ✅ Domain models created
|
||||||
|
- ✅ API methods implemented
|
||||||
|
- ✅ OAuthRepository created
|
||||||
|
- ✅ OAuthManager created
|
||||||
|
- ✅ Unit tests for PKCE
|
||||||
|
|
||||||
|
### ✅ Phase 3: Browser Integration (COMPLETE)
|
||||||
|
- ✅ OAuthSession wraps ASWebAuthenticationSession
|
||||||
|
- ✅ OAuthFlowCoordinator manages complete flow
|
||||||
|
- ✅ URL scheme registered (readeck://)
|
||||||
|
- ✅ State verification
|
||||||
|
- ✅ Error handling
|
||||||
|
|
||||||
|
### ✅ Phase 4: Token Management (COMPLETE)
|
||||||
|
- ✅ AuthenticationMethod enum
|
||||||
|
- ✅ TokenProvider extended with OAuth methods
|
||||||
|
- ✅ `setEndpoint()` added to TokenProvider
|
||||||
|
- ✅ KeychainHelper extended
|
||||||
|
- ✅ Token type detection in getToken()
|
||||||
|
- ✅ AuthRepository extended
|
||||||
|
- ✅ Endpoint saved in both TokenProvider and Settings
|
||||||
|
|
||||||
|
### 🟡 Phase 5: Use Cases & ViewModels (MOSTLY COMPLETE)
|
||||||
|
- ✅ LoginWithOAuthUseCase created
|
||||||
|
- ✅ AuthRepository extended
|
||||||
|
- ✅ SettingsServerViewModel extended
|
||||||
|
- ✅ Factories updated
|
||||||
|
- ❌ LoginMethodSelectionView removed (design change)
|
||||||
|
- ❌ Integration tests not complete
|
||||||
|
|
||||||
|
### 🟡 Phase 6: UI Implementation (MOSTLY COMPLETE)
|
||||||
|
- ✅ OnboardingServerView refactored (2-phase flow)
|
||||||
|
- ✅ Readeck logo with green background
|
||||||
|
- ✅ Dynamic text based on phase
|
||||||
|
- ✅ Conditional login fields
|
||||||
|
- ✅ OAuth auto-attempt logic
|
||||||
|
- ❌ Settings migration UI not implemented
|
||||||
|
- ❌ OAuth token info display not implemented
|
||||||
|
|
||||||
|
### ❌ Phase 7: Testing & Polish (NOT STARTED)
|
||||||
|
- ❌ Manual testing checklist
|
||||||
|
- ❌ Integration tests
|
||||||
|
- ❌ Performance testing
|
||||||
|
- ❌ Code review
|
||||||
|
|
||||||
|
### ❌ Phase 8: Migration & Documentation (NOT STARTED)
|
||||||
|
- ❌ Migration flow for existing users
|
||||||
|
- ❌ Release notes
|
||||||
|
- ❌ Documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Known Issues & TODOs
|
||||||
|
|
||||||
|
### Critical Issues
|
||||||
|
|
||||||
|
1. **API Calls Don't Use OAuth Token Properly**
|
||||||
|
- Problem: When using OAuth, API calls may not be sending the Bearer token correctly
|
||||||
|
- The `TokenProvider.getToken()` checks auth method and returns OAuth access token
|
||||||
|
- But need to verify API calls actually use it in Authorization header
|
||||||
|
- Check if API.swift properly uses `await tokenProvider.getToken()` for all authenticated requests
|
||||||
|
|
||||||
|
2. **Endpoint Parameter Flow Needs Verification**
|
||||||
|
- `GetServerInfoUseCase.execute(endpoint:)` receives endpoint parameter
|
||||||
|
- `ServerInfoRepository.getServerInfo(endpoint:)` passes it through
|
||||||
|
- `InfoApiClient.getServerInfo(endpoint:)` uses it OR falls back to TokenProvider
|
||||||
|
- Need to verify this chain works correctly
|
||||||
|
|
||||||
|
### Minor Issues
|
||||||
|
|
||||||
|
3. **No Migration UI for Existing Users**
|
||||||
|
- Users with API tokens can't migrate to OAuth yet
|
||||||
|
- Settings screen needs "Switch to OAuth" button
|
||||||
|
|
||||||
|
4. **No OAuth Token Display**
|
||||||
|
- Can't see which auth method is active
|
||||||
|
- Can't see token expiry or scopes
|
||||||
|
|
||||||
|
5. **All `#if` Checks Removed**
|
||||||
|
- Removed all `#if os(iOS) && !APP_EXTENSION` conditionals
|
||||||
|
- May cause issues with URLShare extension (needs verification)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Updated TODO List
|
||||||
|
|
||||||
|
### 🔴 IMMEDIATE (Critical for functionality)
|
||||||
|
|
||||||
|
- [ ] **Verify API Integration with OAuth Token**
|
||||||
|
- [ ] Check that `API.swift` uses `await tokenProvider.getToken()` in all authenticated requests
|
||||||
|
- [ ] Verify Bearer token is sent correctly in Authorization header
|
||||||
|
- [ ] Test actual API call (e.g., fetch bookmarks) with OAuth token
|
||||||
|
- [ ] Add logging to confirm which token type is being used
|
||||||
|
|
||||||
|
- [ ] **Test Complete OAuth Flow End-to-End**
|
||||||
|
- [ ] Fresh install → OAuth server → OAuth login → fetch data
|
||||||
|
- [ ] Fresh install → non-OAuth server → classic login → fetch data
|
||||||
|
- [ ] OAuth login failure → fallback to classic login
|
||||||
|
- [ ] Verify endpoint is saved correctly in TokenProvider
|
||||||
|
|
||||||
|
### 🟡 HIGH PRIORITY (Important for UX)
|
||||||
|
|
||||||
|
- [ ] **Settings Screen Migration**
|
||||||
|
- [ ] Show current authentication method (OAuth vs API Token)
|
||||||
|
- [ ] Add "Switch to OAuth" button (if server supports it and user has API token)
|
||||||
|
- [ ] Add "Switch to Classic Login" option
|
||||||
|
- [ ] Show OAuth token info (scopes, expiry if available)
|
||||||
|
|
||||||
|
- [ ] **Error Handling & Messages**
|
||||||
|
- [ ] Better error messages for OAuth failures
|
||||||
|
- [ ] Network error handling
|
||||||
|
- [ ] Token expiry handling
|
||||||
|
- [ ] Server not reachable scenarios
|
||||||
|
|
||||||
|
### 🟢 MEDIUM PRIORITY (Nice to have)
|
||||||
|
|
||||||
|
- [ ] **Testing**
|
||||||
|
- [ ] Manual testing on real device
|
||||||
|
- [ ] Integration tests for OAuth flow
|
||||||
|
- [ ] Test on different iOS versions
|
||||||
|
- [ ] Test with different server versions
|
||||||
|
|
||||||
|
- [ ] **Documentation**
|
||||||
|
- [ ] Update README with OAuth instructions
|
||||||
|
- [ ] Add inline code documentation
|
||||||
|
- [ ] Create user guide for OAuth setup
|
||||||
|
|
||||||
|
### ⚪ LOW PRIORITY (Future enhancements)
|
||||||
|
|
||||||
|
- [ ] Token refresh support (if server implements it)
|
||||||
|
- [ ] Revoke token on logout
|
||||||
|
- [ ] Multiple account support
|
||||||
|
- [ ] Biometric re-authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Implementation Notes
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
1. **No Login Method Selection Screen**
|
||||||
|
- Original plan had LoginMethodSelectionView
|
||||||
|
- Removed in favor of automatic OAuth attempt
|
||||||
|
- Rationale: Better UX, fewer clicks, OAuth is preferred
|
||||||
|
- Fallback to classic login happens automatically on error
|
||||||
|
|
||||||
|
2. **Endpoint in TokenProvider**
|
||||||
|
- Endpoint now saved in BOTH TokenProvider (Keychain) and Settings
|
||||||
|
- Rationale: InfoApiClient needs endpoint from TokenProvider for /api/info calls
|
||||||
|
- Ensures endpoint is available for all API calls
|
||||||
|
|
||||||
|
3. **Removed All `#if os(iOS) && !APP_EXTENSION`**
|
||||||
|
- Original code had many conditional compilation checks
|
||||||
|
- All removed for cleaner code
|
||||||
|
- May need to revisit if URLShare extension has issues
|
||||||
|
|
||||||
|
4. **ServerInfoDto Structure Changed**
|
||||||
|
- Original plan had flat version string
|
||||||
|
- Actual API returns nested VersionInfo object
|
||||||
|
- Adapted to match real API response
|
||||||
|
|
||||||
|
### Code Patterns
|
||||||
|
|
||||||
|
**Endpoint Resolution:**
|
||||||
|
```swift
|
||||||
|
// InfoApiClient
|
||||||
|
func getServerInfo(endpoint: String? = nil) async throws -> ServerInfoDto {
|
||||||
|
let baseEndpoint = try await resolveEndpoint(endpoint)
|
||||||
|
// Uses provided endpoint OR falls back to tokenProvider.getEndpoint()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Type Detection:**
|
||||||
|
```swift
|
||||||
|
// KeychainTokenProvider
|
||||||
|
func getToken() async -> String? {
|
||||||
|
if let method = await getAuthMethod(), method == .oauth {
|
||||||
|
return await getOAuthToken()?.accessToken
|
||||||
|
}
|
||||||
|
return keychainHelper.loadToken() // API token
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OAuth Flow:**
|
||||||
|
```swift
|
||||||
|
// 5-phase flow in OAuthFlowCoordinator
|
||||||
|
1. Register client + generate PKCE
|
||||||
|
2. Build authorization URL
|
||||||
|
3. Open browser (ASWebAuthenticationSession)
|
||||||
|
4. Parse callback URL
|
||||||
|
5. Exchange code for token
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Next Steps
|
||||||
|
|
||||||
|
### For Next Development Session
|
||||||
|
|
||||||
|
1. **Verify API Integration** (Most Critical)
|
||||||
|
- Add logging to see which token is being used
|
||||||
|
- Test actual API call with OAuth token
|
||||||
|
- Confirm Bearer token in Authorization header
|
||||||
|
|
||||||
|
2. **Manual Testing**
|
||||||
|
- Test OAuth flow end-to-end
|
||||||
|
- Test fallback to classic login
|
||||||
|
- Test on real device
|
||||||
|
|
||||||
|
3. **Settings Screen**
|
||||||
|
- Show current auth method
|
||||||
|
- Add migration button
|
||||||
|
|
||||||
|
4. **Error Handling**
|
||||||
|
- Better error messages
|
||||||
|
- Handle all failure scenarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** December 19, 2025
|
||||||
|
**Status:** Phase 1-4 Complete, Phase 5-6 Mostly Complete, Testing Needed
|
||||||
5394
openapi.json
Normal file
5394
openapi.json
Normal file
File diff suppressed because one or more lines are too long
@ -10,6 +10,8 @@
|
|||||||
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; };
|
||||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
|
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5D48E6012EB402F50043F90F /* MarkdownUI */; };
|
||||||
|
5D5230352EF2A4F4002FDEDE /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 5D5230342EF2A4F4002FDEDE /* AppAuth */; };
|
||||||
|
5D5230372EF2A4F4002FDEDE /* AppAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5D5230362EF2A4F4002FDEDE /* AppAuthCore */; };
|
||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; };
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -81,6 +83,7 @@
|
|||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Assets.xcassets,
|
Assets.xcassets,
|
||||||
|
Data/API/DTOs/OAuthTokenResponseDto.swift,
|
||||||
Data/CoreData/CoreDataManager.swift,
|
Data/CoreData/CoreDataManager.swift,
|
||||||
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
|
"Data/Extensions/NSManagedObjectContext+SafeFetch.swift",
|
||||||
Data/KeychainHelper.swift,
|
Data/KeychainHelper.swift,
|
||||||
@ -94,6 +97,8 @@
|
|||||||
Domain/Model/TagSortOrder.swift,
|
Domain/Model/TagSortOrder.swift,
|
||||||
Domain/Model/Theme.swift,
|
Domain/Model/Theme.swift,
|
||||||
Domain/Model/UrlOpener.swift,
|
Domain/Model/UrlOpener.swift,
|
||||||
|
Domain/Models/AuthenticationMethod.swift,
|
||||||
|
Domain/Models/OAuthToken.swift,
|
||||||
readeck.xcdatamodeld,
|
readeck.xcdatamodeld,
|
||||||
Splash.storyboard,
|
Splash.storyboard,
|
||||||
UI/Components/Constants.swift,
|
UI/Components/Constants.swift,
|
||||||
@ -103,8 +108,9 @@
|
|||||||
UI/Components/UnifiedLabelChip.swift,
|
UI/Components/UnifiedLabelChip.swift,
|
||||||
UI/Extension/FontSizeExtension.swift,
|
UI/Extension/FontSizeExtension.swift,
|
||||||
UI/Models/AppSettings.swift,
|
UI/Models/AppSettings.swift,
|
||||||
"UI/Utils 2/Logger.swift",
|
UI/Utils/BuildEnvironment.swift,
|
||||||
"UI/Utils 2/LogStore.swift",
|
UI/Utils/Logger.swift,
|
||||||
|
UI/Utils/LogStore.swift,
|
||||||
UI/Utils/NotificationNames.swift,
|
UI/Utils/NotificationNames.swift,
|
||||||
);
|
);
|
||||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||||
@ -160,10 +166,12 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
5D5230352EF2A4F4002FDEDE /* AppAuth in Frameworks */,
|
||||||
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
5D9D95492E623668009AF769 /* Kingfisher in Frameworks */,
|
||||||
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */,
|
||||||
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */,
|
||||||
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
|
5D48E6022EB402F50043F90F /* MarkdownUI in Frameworks */,
|
||||||
|
5D5230372EF2A4F4002FDEDE /* AppAuthCore in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -256,6 +264,8 @@
|
|||||||
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
5DA241FA2E17C3B3007531C3 /* RswiftLibrary */,
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */,
|
5D9D95482E623668009AF769 /* Kingfisher */,
|
||||||
5D48E6012EB402F50043F90F /* MarkdownUI */,
|
5D48E6012EB402F50043F90F /* MarkdownUI */,
|
||||||
|
5D5230342EF2A4F4002FDEDE /* AppAuth */,
|
||||||
|
5D5230362EF2A4F4002FDEDE /* AppAuthCore */,
|
||||||
);
|
);
|
||||||
productName = readeck;
|
productName = readeck;
|
||||||
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */;
|
||||||
@ -348,6 +358,7 @@
|
|||||||
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */,
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */,
|
||||||
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
|
||||||
|
5D5230332EF2A4F4002FDEDE /* XCRemoteSwiftPackageReference "AppAuth-iOS" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */;
|
||||||
@ -640,7 +651,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 42;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -684,7 +695,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 42;
|
CURRENT_PROJECT_VERSION = 43;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -877,6 +888,14 @@
|
|||||||
minimumVersion = 2.4.1;
|
minimumVersion = 2.4.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
5D5230332EF2A4F4002FDEDE /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/openid/AppAuth-iOS.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 2.0.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
|
||||||
@ -906,6 +925,16 @@
|
|||||||
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
package = 5D48E6002EB402F50043F90F /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
|
||||||
productName = MarkdownUI;
|
productName = MarkdownUI;
|
||||||
};
|
};
|
||||||
|
5D5230342EF2A4F4002FDEDE /* AppAuth */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5D5230332EF2A4F4002FDEDE /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
|
||||||
|
productName = AppAuth;
|
||||||
|
};
|
||||||
|
5D5230362EF2A4F4002FDEDE /* AppAuthCore */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 5D5230332EF2A4F4002FDEDE /* XCRemoteSwiftPackageReference "AppAuth-iOS" */;
|
||||||
|
productName = AppAuthCore;
|
||||||
|
};
|
||||||
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
5D9D95482E623668009AF769 /* Kingfisher */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */;
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "77d424216eb5411f97bf8ee011ef543bf97f05ec343dfe49b8c22bc78da99635",
|
"originHash" : "b964581e1e8d29df3b25b108bf84266454f34a1735bfe719e1926b2c41006a25",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "appauth-ios",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/openid/AppAuth-iOS.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e",
|
||||||
|
"version" : "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "kingfisher",
|
"identity" : "kingfisher",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -21,6 +21,10 @@ protocol PAPI {
|
|||||||
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
func getBookmarkAnnotations(bookmarkId: String) async throws -> [AnnotationDto]
|
||||||
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
|
func createAnnotation(bookmarkId: String, color: String, startOffset: Int, endOffset: Int, startSelector: String, endSelector: String) async throws -> AnnotationDto
|
||||||
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
func deleteAnnotation(bookmarkId: String, annotationId: String) async throws
|
||||||
|
|
||||||
|
// OAuth methods
|
||||||
|
func registerOAuthClient(endpoint: String, request: OAuthClientCreateDto) async throws -> OAuthClientResponseDto
|
||||||
|
func exchangeOAuthToken(endpoint: String, request: OAuthTokenRequestDto) async throws -> OAuthTokenResponseDto
|
||||||
}
|
}
|
||||||
|
|
||||||
class API: PAPI {
|
class API: PAPI {
|
||||||
@ -530,6 +534,87 @@ class API: PAPI {
|
|||||||
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
logger.info("Successfully deleted annotation: \(annotationId)")
|
logger.info("Successfully deleted annotation: \(annotationId)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Methods
|
||||||
|
|
||||||
|
func registerOAuthClient(endpoint: String, request: OAuthClientCreateDto) async throws -> OAuthClientResponseDto {
|
||||||
|
logger.info("Registering OAuth client for endpoint: \(endpoint)")
|
||||||
|
guard let url = URL(string: "\(endpoint)/api/oauth/client") else {
|
||||||
|
logger.error("Invalid URL for OAuth client registration: \(endpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestData = try JSONEncoder().encode(request)
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
urlRequest.httpBody = requestData
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for OAuth client registration")
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
let clientResponse = try JSONDecoder().decode(OAuthClientResponseDto.self, from: data)
|
||||||
|
logger.info("Successfully registered OAuth client: \(clientResponse.clientId)")
|
||||||
|
return clientResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeOAuthToken(endpoint: String, request: OAuthTokenRequestDto) async throws -> OAuthTokenResponseDto {
|
||||||
|
logger.info("Exchanging OAuth authorization code for access token")
|
||||||
|
guard let url = URL(string: "\(endpoint)/api/oauth/token") else {
|
||||||
|
logger.error("Invalid URL for OAuth token exchange: \(endpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth token requests typically use application/x-www-form-urlencoded
|
||||||
|
let formData = [
|
||||||
|
"grant_type": request.grantType,
|
||||||
|
"client_id": request.clientId,
|
||||||
|
"code": request.code,
|
||||||
|
"code_verifier": request.codeVerifier,
|
||||||
|
"redirect_uri": request.redirectUri
|
||||||
|
]
|
||||||
|
|
||||||
|
let formBody = formData.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" }
|
||||||
|
.joined(separator: "&")
|
||||||
|
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
urlRequest.httpBody = formBody.data(using: .utf8)
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for OAuth token exchange")
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "POST", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
let tokenResponse = try JSONDecoder().decode(OAuthTokenResponseDto.self, from: data)
|
||||||
|
logger.info("Successfully exchanged authorization code for access token")
|
||||||
|
return tokenResponse
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
|||||||
28
readeck/Data/API/DTOs/OAuthClientCreateDto.swift
Normal file
28
readeck/Data/API/DTOs/OAuthClientCreateDto.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// OAuthClientCreateDto.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Request DTO for creating an OAuth client
|
||||||
|
/// According to RFC 7591 (OAuth 2.0 Dynamic Client Registration)
|
||||||
|
struct OAuthClientCreateDto: Codable {
|
||||||
|
let clientName: String
|
||||||
|
let clientUri: String
|
||||||
|
let softwareId: String
|
||||||
|
let softwareVersion: String
|
||||||
|
let redirectUris: [String]
|
||||||
|
let grantTypes: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientName = "client_name"
|
||||||
|
case clientUri = "client_uri"
|
||||||
|
case softwareId = "software_id"
|
||||||
|
case softwareVersion = "software_version"
|
||||||
|
case redirectUris = "redirect_uris"
|
||||||
|
case grantTypes = "grant_types"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
readeck/Data/API/DTOs/OAuthClientResponseDto.swift
Normal file
25
readeck/Data/API/DTOs/OAuthClientResponseDto.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// OAuthClientResponseDto.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Response DTO from OAuth client registration
|
||||||
|
struct OAuthClientResponseDto: Codable {
|
||||||
|
let clientId: String
|
||||||
|
let clientSecret: String?
|
||||||
|
let clientName: String
|
||||||
|
let redirectUris: [String]
|
||||||
|
let grantTypes: [String]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case clientId = "client_id"
|
||||||
|
case clientSecret = "client_secret"
|
||||||
|
case clientName = "client_name"
|
||||||
|
case redirectUris = "redirect_uris"
|
||||||
|
case grantTypes = "grant_types"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
readeck/Data/API/DTOs/OAuthTokenRequestDto.swift
Normal file
25
readeck/Data/API/DTOs/OAuthTokenRequestDto.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// OAuthTokenRequestDto.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Request DTO for exchanging authorization code for access token
|
||||||
|
struct OAuthTokenRequestDto: Codable {
|
||||||
|
let grantType: String
|
||||||
|
let clientId: String
|
||||||
|
let code: String
|
||||||
|
let codeVerifier: String
|
||||||
|
let redirectUri: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case grantType = "grant_type"
|
||||||
|
case clientId = "client_id"
|
||||||
|
case code
|
||||||
|
case codeVerifier = "code_verifier"
|
||||||
|
case redirectUri = "redirect_uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
readeck/Data/API/DTOs/OAuthTokenResponseDto.swift
Normal file
25
readeck/Data/API/DTOs/OAuthTokenResponseDto.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// OAuthTokenResponseDto.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Response DTO from OAuth token exchange
|
||||||
|
struct OAuthTokenResponseDto: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let scope: String?
|
||||||
|
let expiresIn: Int?
|
||||||
|
let refreshToken: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case accessToken = "access_token"
|
||||||
|
case tokenType = "token_type"
|
||||||
|
case scope
|
||||||
|
case expiresIn = "expires_in"
|
||||||
|
case refreshToken = "refresh_token"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct ServerInfoDto: Codable {
|
struct ServerInfoDto: Codable {
|
||||||
let version: String
|
let version: VersionInfo
|
||||||
let buildDate: String?
|
let features: [String]?
|
||||||
let userAgent: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
struct VersionInfo: Codable {
|
||||||
case version
|
let canonical: String
|
||||||
case buildDate = "build_date"
|
let release: String
|
||||||
case userAgent = "user_agent"
|
let build: String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
readeck/Data/API/DTOs/UserProfileDto.swift
Normal file
29
readeck/Data/API/DTOs/UserProfileDto.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// UserProfileDto.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 19.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// User profile response DTO from /api/profile
|
||||||
|
struct UserProfileDto: Codable {
|
||||||
|
let user: UserDto
|
||||||
|
let provider: ProviderDto
|
||||||
|
|
||||||
|
struct UserDto: Codable {
|
||||||
|
let username: String
|
||||||
|
let email: String?
|
||||||
|
let created: String?
|
||||||
|
let updated: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProviderDto: Codable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let application: String?
|
||||||
|
let roles: [String]?
|
||||||
|
let permissions: [String]?
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol PInfoApiClient {
|
protocol PInfoApiClient {
|
||||||
func getServerInfo() async throws -> ServerInfoDto
|
func getServerInfo(endpoint: String?) async throws -> ServerInfoDto
|
||||||
}
|
}
|
||||||
|
|
||||||
class InfoApiClient: PInfoApiClient {
|
class InfoApiClient: PInfoApiClient {
|
||||||
@ -18,21 +18,10 @@ class InfoApiClient: PInfoApiClient {
|
|||||||
self.tokenProvider = tokenProvider
|
self.tokenProvider = tokenProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
func getServerInfo() async throws -> ServerInfoDto {
|
func getServerInfo(endpoint: String? = nil) async throws -> ServerInfoDto {
|
||||||
guard let endpoint = await tokenProvider.getEndpoint(),
|
let baseEndpoint = try await resolveEndpoint(endpoint)
|
||||||
let url = URL(string: "\(endpoint)/api/info") else {
|
let url = try buildInfoURL(baseEndpoint: baseEndpoint)
|
||||||
logger.error("Invalid endpoint URL for server info")
|
let request = try await buildInfoRequest(url: url, useStoredEndpoint: endpoint == nil)
|
||||||
throw APIError.invalidURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.httpMethod = "GET"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
request.timeoutInterval = 5.0
|
|
||||||
|
|
||||||
if let token = await tokenProvider.getToken() {
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
|
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
|
||||||
|
|
||||||
@ -52,4 +41,39 @@ class InfoApiClient: PInfoApiClient {
|
|||||||
|
|
||||||
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
|
return try JSONDecoder().decode(ServerInfoDto.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func resolveEndpoint(_ providedEndpoint: String?) async throws -> String {
|
||||||
|
if let providedEndpoint = providedEndpoint {
|
||||||
|
return providedEndpoint
|
||||||
|
} else if let storedEndpoint = await tokenProvider.getEndpoint() {
|
||||||
|
return storedEndpoint
|
||||||
|
} else {
|
||||||
|
logger.error("No endpoint available for server info")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildInfoURL(baseEndpoint: String) throws -> URL {
|
||||||
|
guard let url = URL(string: "\(baseEndpoint)/api/info") else {
|
||||||
|
logger.error("Invalid endpoint URL for server info: \(baseEndpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildInfoRequest(url: URL, useStoredEndpoint: Bool) async throws -> URLRequest {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
request.timeoutInterval = 5.0
|
||||||
|
|
||||||
|
// Only add token if using stored endpoint (not custom endpoint)
|
||||||
|
if useStoredEndpoint, let token = await tokenProvider.getToken() {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
readeck/Data/API/ProfileApiClient.swift
Normal file
79
readeck/Data/API/ProfileApiClient.swift
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ProfileApiClient.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 19.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PProfileApiClient {
|
||||||
|
func getProfile() async throws -> UserProfileDto
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileApiClient: PProfileApiClient {
|
||||||
|
private let tokenProvider: TokenProvider
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
init(tokenProvider: TokenProvider = KeychainTokenProvider()) {
|
||||||
|
self.tokenProvider = tokenProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProfile() async throws -> UserProfileDto {
|
||||||
|
let endpoint = try await resolveEndpoint()
|
||||||
|
let url = try buildProfileURL(baseEndpoint: endpoint)
|
||||||
|
let request = try await buildProfileRequest(url: url)
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: url.absoluteString)
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
logger.error("Invalid HTTP response for user profile")
|
||||||
|
throw APIError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
logger.logNetworkError(method: "GET", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode))
|
||||||
|
throw APIError.serverError(httpResponse.statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.logNetworkRequest(method: "GET", url: url.absoluteString, statusCode: httpResponse.statusCode)
|
||||||
|
|
||||||
|
return try JSONDecoder().decode(UserProfileDto.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func resolveEndpoint() async throws -> String {
|
||||||
|
guard let endpoint = await tokenProvider.getEndpoint() else {
|
||||||
|
logger.error("No endpoint available for user profile")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildProfileURL(baseEndpoint: String) throws -> URL {
|
||||||
|
guard let url = URL(string: "\(baseEndpoint)/api/profile") else {
|
||||||
|
logger.error("Invalid endpoint URL for user profile: \(baseEndpoint)")
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildProfileRequest(url: URL) async throws -> URLRequest {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
|
||||||
|
// Profile endpoint requires authentication
|
||||||
|
guard let token = await tokenProvider.getToken() else {
|
||||||
|
logger.error("No authentication token available for user profile")
|
||||||
|
throw APIError.serverError(401)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -76,7 +76,6 @@ class CoreDataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
func resetDatabase() throws {
|
func resetDatabase() throws {
|
||||||
logger.warning("⚠️ Resetting Core Data database - ALL DATA WILL BE DELETED")
|
logger.warning("⚠️ Resetting Core Data database - ALL DATA WILL BE DELETED")
|
||||||
|
|
||||||
@ -103,7 +102,6 @@ class CoreDataManager {
|
|||||||
|
|
||||||
logger.info("Core Data database files deleted successfully")
|
logger.info("Core Data database files deleted successfully")
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
private func setupInMemoryStore(container: NSPersistentContainer) {
|
private func setupInMemoryStore(container: NSPersistentContainer) {
|
||||||
logger.warning("Setting up in-memory Core Data store as fallback")
|
logger.warning("Setting up in-memory Core Data store as fallback")
|
||||||
|
|||||||
@ -43,13 +43,49 @@ class KeychainHelper {
|
|||||||
loadString(forKey: "readeck_password")
|
loadString(forKey: "readeck_password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Token Storage
|
||||||
|
// Note: OAuth is only available in the main app target, not in URLShare extension
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveOAuthToken(_ token: OAuthToken) -> Bool {
|
||||||
|
guard let data = try? JSONEncoder().encode(token),
|
||||||
|
let jsonString = String(data: data, encoding: .utf8) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return saveString(jsonString, forKey: "readeck_oauth_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOAuthToken() -> OAuthToken? {
|
||||||
|
guard let jsonString = loadString(forKey: "readeck_oauth_token"),
|
||||||
|
let data = jsonString.data(using: .utf8),
|
||||||
|
let token = try? JSONDecoder().decode(OAuthToken.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func saveAuthMethod(_ method: AuthenticationMethod) -> Bool {
|
||||||
|
saveString(method.rawValue, forKey: "readeck_auth_method")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAuthMethod() -> AuthenticationMethod? {
|
||||||
|
guard let rawValue = loadString(forKey: "readeck_auth_method") else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return AuthenticationMethod(rawValue: rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func clearCredentials() -> Bool {
|
func clearCredentials() -> Bool {
|
||||||
let tokenCleared = saveString("", forKey: "readeck_token")
|
let tokenCleared = saveString("", forKey: "readeck_token")
|
||||||
let endpointCleared = saveString("", forKey: "readeck_endpoint")
|
let endpointCleared = saveString("", forKey: "readeck_endpoint")
|
||||||
let usernameCleared = saveString("", forKey: "readeck_username")
|
let usernameCleared = saveString("", forKey: "readeck_username")
|
||||||
let passwordCleared = saveString("", forKey: "readeck_password")
|
let passwordCleared = saveString("", forKey: "readeck_password")
|
||||||
return tokenCleared && endpointCleared && usernameCleared && passwordCleared
|
|
||||||
|
let oauthTokenCleared = saveString("", forKey: "readeck_oauth_token")
|
||||||
|
let authMethodCleared = saveString("", forKey: "readeck_auth_method")
|
||||||
|
return tokenCleared && endpointCleared && usernameCleared && passwordCleared && oauthTokenCleared && authMethodCleared
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private generic helpers
|
// MARK: - Private generic helpers
|
||||||
|
|||||||
122
readeck/Data/OAuth/OAuthFlowCoordinator.swift
Normal file
122
readeck/Data/OAuth/OAuthFlowCoordinator.swift
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
//
|
||||||
|
// OAuthFlowCoordinator.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 16.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Coordinates the complete OAuth 2.0 flow from start to finish
|
||||||
|
@MainActor
|
||||||
|
class OAuthFlowCoordinator {
|
||||||
|
private let manager: OAuthManager
|
||||||
|
private let session: OAuthSession
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
// Temporary storage for OAuth flow state
|
||||||
|
private var currentClient: OAuthClient?
|
||||||
|
private var currentVerifier: String?
|
||||||
|
private var currentState: String?
|
||||||
|
private var currentEndpoint: String?
|
||||||
|
|
||||||
|
init(manager: OAuthManager) {
|
||||||
|
self.manager = manager
|
||||||
|
self.session = OAuthSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the complete OAuth flow
|
||||||
|
/// - Parameter endpoint: Server endpoint URL
|
||||||
|
/// - Returns: OAuth access token
|
||||||
|
func executeOAuthFlow(endpoint: String) async throws -> OAuthToken {
|
||||||
|
logger.info("🔐 Starting OAuth flow for endpoint: \(endpoint)")
|
||||||
|
|
||||||
|
// Phase 1: Register client and generate PKCE
|
||||||
|
logger.info("Phase 1: Registering OAuth client...")
|
||||||
|
let (client, verifier, challenge, state) = try await manager.startOAuthFlow(endpoint: endpoint)
|
||||||
|
|
||||||
|
// Store state for later use
|
||||||
|
self.currentClient = client
|
||||||
|
self.currentVerifier = verifier
|
||||||
|
self.currentState = state
|
||||||
|
self.currentEndpoint = endpoint
|
||||||
|
|
||||||
|
logger.info("✅ Client registered: \(client.clientId)")
|
||||||
|
logger.info("🔑 PKCE challenge generated")
|
||||||
|
|
||||||
|
// Phase 2: Build authorization URL
|
||||||
|
guard let authURL = manager.buildAuthorizationURL(
|
||||||
|
endpoint: endpoint,
|
||||||
|
clientId: client.clientId,
|
||||||
|
codeChallenge: challenge,
|
||||||
|
state: state
|
||||||
|
) else {
|
||||||
|
logger.error("Failed to build authorization URL")
|
||||||
|
throw OAuthError.invalidCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("🌐 Authorization URL: \(authURL.absoluteString)")
|
||||||
|
|
||||||
|
// Phase 3: Open browser for user authentication
|
||||||
|
logger.info("Phase 2: Opening browser for user authentication...")
|
||||||
|
let callbackURL = try await withCheckedThrowingContinuation { continuation in
|
||||||
|
session.start(
|
||||||
|
url: authURL,
|
||||||
|
callbackURLScheme: "readeck"
|
||||||
|
) { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("✅ Received callback: \(callbackURL.absoluteString)")
|
||||||
|
|
||||||
|
// Phase 4: Parse callback URL
|
||||||
|
guard let (code, receivedState) = OAuthManager.parseCallbackURL(callbackURL) else {
|
||||||
|
logger.error("Failed to parse callback URL")
|
||||||
|
throw OAuthError.invalidCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("📋 Authorization code received")
|
||||||
|
logger.info("🔐 State verification...")
|
||||||
|
|
||||||
|
// Phase 5: Exchange code for token
|
||||||
|
guard let savedState = currentState,
|
||||||
|
let savedVerifier = currentVerifier,
|
||||||
|
let savedClient = currentClient else {
|
||||||
|
logger.error("OAuth flow state was lost")
|
||||||
|
throw OAuthError.invalidCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Phase 3: Exchanging authorization code for access token...")
|
||||||
|
let token = try await manager.completeOAuthFlow(
|
||||||
|
endpoint: endpoint,
|
||||||
|
clientId: savedClient.clientId,
|
||||||
|
code: code,
|
||||||
|
codeVerifier: savedVerifier,
|
||||||
|
receivedState: receivedState,
|
||||||
|
expectedState: savedState
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Access token obtained successfully")
|
||||||
|
logger.info("🎉 OAuth flow completed!")
|
||||||
|
|
||||||
|
// Clean up state
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the ongoing OAuth flow
|
||||||
|
func cancelFlow() {
|
||||||
|
logger.info("❌ Cancelling OAuth flow")
|
||||||
|
session.cancel()
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanup() {
|
||||||
|
currentClient = nil
|
||||||
|
currentVerifier = nil
|
||||||
|
currentState = nil
|
||||||
|
currentEndpoint = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
152
readeck/Data/OAuth/OAuthManager.swift
Normal file
152
readeck/Data/OAuth/OAuthManager.swift
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
//
|
||||||
|
// OAuthManager.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Orchestrates the OAuth 2.0 Authorization Code flow with PKCE
|
||||||
|
class OAuthManager {
|
||||||
|
private let repository: POAuthRepository
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
// OAuth configuration
|
||||||
|
static let redirectUri = "readeck://oauth-callback"
|
||||||
|
static let clientName = "Readeck iOS"
|
||||||
|
static let scopes = "bookmarks:read bookmarks:write profile:read"
|
||||||
|
|
||||||
|
init(repository: POAuthRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the authorization URL for OAuth flow
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: Server endpoint URL
|
||||||
|
/// - clientId: OAuth client ID
|
||||||
|
/// - codeChallenge: PKCE code challenge
|
||||||
|
/// - state: CSRF protection state
|
||||||
|
/// - Returns: Authorization URL
|
||||||
|
func buildAuthorizationURL(
|
||||||
|
endpoint: String,
|
||||||
|
clientId: String,
|
||||||
|
codeChallenge: String,
|
||||||
|
state: String
|
||||||
|
) -> URL? {
|
||||||
|
var components = URLComponents(string: "\(endpoint)/authorize")
|
||||||
|
components?.queryItems = [
|
||||||
|
URLQueryItem(name: "client_id", value: clientId),
|
||||||
|
URLQueryItem(name: "redirect_uri", value: Self.redirectUri),
|
||||||
|
URLQueryItem(name: "scope", value: Self.scopes),
|
||||||
|
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
||||||
|
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||||
|
URLQueryItem(name: "state", value: state),
|
||||||
|
URLQueryItem(name: "response_type", value: "code")
|
||||||
|
]
|
||||||
|
return components?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the OAuth flow by registering a client
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: Server endpoint URL
|
||||||
|
/// - Returns: Tuple containing (client, PKCE verifier, PKCE challenge, state)
|
||||||
|
func startOAuthFlow(endpoint: String) async throws -> (client: OAuthClient, verifier: String, challenge: String, state: String) {
|
||||||
|
logger.info("Starting OAuth flow for endpoint: \(endpoint)")
|
||||||
|
|
||||||
|
// Generate PKCE
|
||||||
|
let (verifier, challenge) = PKCEGenerator.generate()
|
||||||
|
|
||||||
|
// Generate CSRF state
|
||||||
|
let state = UUID().uuidString
|
||||||
|
|
||||||
|
// Register OAuth client
|
||||||
|
let client = try await repository.registerClient(
|
||||||
|
endpoint: endpoint,
|
||||||
|
clientName: Self.clientName,
|
||||||
|
redirectUri: Self.redirectUri
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("OAuth client registered with ID: \(client.clientId)")
|
||||||
|
|
||||||
|
return (client, verifier, challenge, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the OAuth flow by exchanging the authorization code for a token
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: Server endpoint URL
|
||||||
|
/// - clientId: OAuth client ID
|
||||||
|
/// - code: Authorization code from redirect
|
||||||
|
/// - codeVerifier: PKCE code verifier
|
||||||
|
/// - receivedState: State from redirect (for CSRF verification)
|
||||||
|
/// - expectedState: Expected state value
|
||||||
|
/// - Returns: OAuth access token
|
||||||
|
func completeOAuthFlow(
|
||||||
|
endpoint: String,
|
||||||
|
clientId: String,
|
||||||
|
code: String,
|
||||||
|
codeVerifier: String,
|
||||||
|
receivedState: String,
|
||||||
|
expectedState: String
|
||||||
|
) async throws -> OAuthToken {
|
||||||
|
logger.info("Completing OAuth flow")
|
||||||
|
|
||||||
|
// Verify state to prevent CSRF attacks
|
||||||
|
guard receivedState == expectedState else {
|
||||||
|
logger.error("OAuth state mismatch - possible CSRF attack")
|
||||||
|
throw OAuthError.stateMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
let token = try await repository.exchangeToken(
|
||||||
|
endpoint: endpoint,
|
||||||
|
clientId: clientId,
|
||||||
|
code: code,
|
||||||
|
codeVerifier: codeVerifier,
|
||||||
|
redirectUri: Self.redirectUri
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Successfully obtained OAuth access token")
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the OAuth callback URL to extract code and state
|
||||||
|
/// - Parameter url: Callback URL
|
||||||
|
/// - Returns: Tuple containing (code, state) if successful, nil otherwise
|
||||||
|
static func parseCallbackURL(_ url: URL) -> (code: String, state: String)? {
|
||||||
|
guard url.scheme == "readeck",
|
||||||
|
url.host == "oauth-callback",
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let queryItems = components.queryItems else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let code = queryItems.first(where: { $0.name == "code" })?.value
|
||||||
|
let state = queryItems.first(where: { $0.name == "state" })?.value
|
||||||
|
|
||||||
|
guard let code = code, let state = state else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return (code, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Errors
|
||||||
|
|
||||||
|
enum OAuthError: LocalizedError {
|
||||||
|
case stateMismatch
|
||||||
|
case invalidCallback
|
||||||
|
case userCancelled
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .stateMismatch:
|
||||||
|
return "OAuth state verification failed (possible CSRF attack)"
|
||||||
|
case .invalidCallback:
|
||||||
|
return "Invalid OAuth callback URL"
|
||||||
|
case .userCancelled:
|
||||||
|
return "OAuth authorization was cancelled by user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
readeck/Data/OAuth/OAuthSession.swift
Normal file
93
readeck/Data/OAuth/OAuthSession.swift
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// OAuthSession.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 16.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AuthenticationServices
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Wrapper for ASWebAuthenticationSession to handle OAuth browser flow
|
||||||
|
@MainActor
|
||||||
|
class OAuthSession: NSObject {
|
||||||
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
/// Starts the OAuth authentication flow in a browser
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: Authorization URL to open
|
||||||
|
/// - callbackURLScheme: Custom URL scheme for callback (e.g., "readeck")
|
||||||
|
/// - completion: Called with callback URL or error
|
||||||
|
func start(
|
||||||
|
url: URL,
|
||||||
|
callbackURLScheme: String,
|
||||||
|
completion: @escaping (Result<URL, Error>) -> Void
|
||||||
|
) {
|
||||||
|
logger.info("Starting OAuth authentication session with URL: \(url.absoluteString)")
|
||||||
|
|
||||||
|
// Create authentication session
|
||||||
|
authSession = ASWebAuthenticationSession(
|
||||||
|
url: url,
|
||||||
|
callbackURLScheme: callbackURLScheme
|
||||||
|
) { [weak self] callbackURL, error in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self.logger.error("OAuth authentication failed: \(error.localizedDescription)")
|
||||||
|
|
||||||
|
// Check if user cancelled
|
||||||
|
if let authError = error as? ASWebAuthenticationSessionError,
|
||||||
|
authError.code == .canceledLogin {
|
||||||
|
self.logger.info("User cancelled OAuth login")
|
||||||
|
completion(.failure(OAuthError.userCancelled))
|
||||||
|
} else {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let callbackURL = callbackURL else {
|
||||||
|
self.logger.error("OAuth callback URL is nil")
|
||||||
|
completion(.failure(OAuthError.invalidCallback))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.logger.info("OAuth callback received: \(callbackURL.absoluteString)")
|
||||||
|
completion(.success(callbackURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure session
|
||||||
|
authSession?.presentationContextProvider = self
|
||||||
|
authSession?.prefersEphemeralWebBrowserSession = false // Allow cookies/saved passwords
|
||||||
|
|
||||||
|
// Start the session
|
||||||
|
guard let session = authSession, session.start() else {
|
||||||
|
logger.error("Failed to start OAuth authentication session")
|
||||||
|
completion(.failure(OAuthError.invalidCallback))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("OAuth authentication session started successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the ongoing authentication session
|
||||||
|
func cancel() {
|
||||||
|
logger.info("Cancelling OAuth authentication session")
|
||||||
|
authSession?.cancel()
|
||||||
|
authSession = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||||
|
|
||||||
|
extension OAuthSession: ASWebAuthenticationPresentationContextProviding {
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
// Return the key window for presenting the authentication session
|
||||||
|
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
|
let window = windowScene.windows.first else {
|
||||||
|
fatalError("No window available for OAuth presentation")
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
}
|
||||||
|
}
|
||||||
48
readeck/Data/OAuth/PKCEGenerator.swift
Normal file
48
readeck/Data/OAuth/PKCEGenerator.swift
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
//
|
||||||
|
// PKCEGenerator.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
/// Generates PKCE (Proof Key for Code Exchange) verifier and challenge for OAuth 2.0
|
||||||
|
/// According to RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636
|
||||||
|
struct PKCEGenerator {
|
||||||
|
|
||||||
|
/// Generates a cryptographically random code verifier
|
||||||
|
/// - Returns: A 64-character random alphanumeric string
|
||||||
|
static func generateCodeVerifier() -> String {
|
||||||
|
var buffer = [UInt8](repeating: 0, count: 64)
|
||||||
|
_ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
|
||||||
|
|
||||||
|
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||||
|
return buffer.map { String(alphabet[Int($0) % alphabet.count]) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a code challenge from a verifier using SHA-256 and base64url encoding
|
||||||
|
/// - Parameter verifier: The code verifier string
|
||||||
|
/// - Returns: Base64url-encoded SHA-256 hash (without padding)
|
||||||
|
static func generateCodeChallenge(from verifier: String) -> String {
|
||||||
|
guard let data = verifier.data(using: .utf8) else { return "" }
|
||||||
|
let hash = SHA256.hash(data: data)
|
||||||
|
let base64 = Data(hash).base64EncodedString()
|
||||||
|
|
||||||
|
// Convert to base64url (RFC 7636 Section 4.2)
|
||||||
|
// Replace '+' with '-', '/' with '_', and remove '=' padding
|
||||||
|
return base64
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates both verifier and challenge in one call
|
||||||
|
/// - Returns: Tuple containing (verifier, challenge)
|
||||||
|
static func generate() -> (verifier: String, challenge: String) {
|
||||||
|
let verifier = generateCodeVerifier()
|
||||||
|
let challenge = generateCodeChallenge(from: verifier)
|
||||||
|
return (verifier, challenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,25 +3,61 @@ import Foundation
|
|||||||
class AuthRepository: PAuthRepository {
|
class AuthRepository: PAuthRepository {
|
||||||
private let api: PAPI
|
private let api: PAPI
|
||||||
private let settingsRepository: PSettingsRepository
|
private let settingsRepository: PSettingsRepository
|
||||||
|
private let getUserProfileUseCase: PGetUserProfileUseCase
|
||||||
init(api: PAPI, settingsRepository: PSettingsRepository) {
|
|
||||||
|
init(api: PAPI, settingsRepository: PSettingsRepository, getUserProfileUseCase: PGetUserProfileUseCase) {
|
||||||
self.api = api
|
self.api = api
|
||||||
self.settingsRepository = settingsRepository
|
self.settingsRepository = settingsRepository
|
||||||
|
self.getUserProfileUseCase = getUserProfileUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
func login(endpoint: String, username: String, password: String) async throws -> User {
|
func login(endpoint: String, username: String, password: String) async throws -> User {
|
||||||
let userDto = try await api.login(endpoint: endpoint, username: username, password: password)
|
let userDto = try await api.login(endpoint: endpoint, username: username, password: password)
|
||||||
// Token wird automatisch von der API gespeichert
|
// Token wird automatisch von der API gespeichert
|
||||||
|
await api.tokenProvider.setAuthMethod(.apiToken)
|
||||||
return User(id: userDto.id, token: userDto.token)
|
return User(id: userDto.id, token: userDto.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() async throws {
|
func logout() async throws {
|
||||||
await api.tokenProvider.clearToken()
|
await api.tokenProvider.clearToken()
|
||||||
|
await api.tokenProvider.setAuthMethod(.apiToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCurrentSettings() async throws -> Settings? {
|
func getCurrentSettings() async throws -> Settings? {
|
||||||
return try await settingsRepository.loadSettings()
|
return try await settingsRepository.loadSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loginWithOAuth(endpoint: String, token: OAuthToken) async throws {
|
||||||
|
// Save OAuth token, auth method, and endpoint
|
||||||
|
await api.tokenProvider.setOAuthToken(token)
|
||||||
|
await api.tokenProvider.setAuthMethod(.oauth)
|
||||||
|
await api.tokenProvider.setEndpoint(endpoint)
|
||||||
|
|
||||||
|
// Fetch username from user profile
|
||||||
|
let username = try await getUserProfileUseCase.execute()
|
||||||
|
|
||||||
|
// Save endpoint and username to settings (token is stored in keychain via tokenProvider)
|
||||||
|
if var settings = try await settingsRepository.loadSettings() {
|
||||||
|
settings.endpoint = endpoint
|
||||||
|
settings.username = username
|
||||||
|
// Note: isLoggedIn is a computed property based on token presence
|
||||||
|
// The OAuth token is already saved via tokenProvider above
|
||||||
|
try await settingsRepository.saveSettings(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthenticationMethod() async -> AuthenticationMethod? {
|
||||||
|
return await api.tokenProvider.getAuthMethod()
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToClassicAuth(endpoint: String, username: String, password: String) async throws -> User {
|
||||||
|
// Clear OAuth token first
|
||||||
|
await api.tokenProvider.clearToken()
|
||||||
|
await api.tokenProvider.setAuthMethod(.apiToken)
|
||||||
|
|
||||||
|
// Then do regular login
|
||||||
|
return try await login(endpoint: endpoint, username: username, password: password)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct User {
|
struct User {
|
||||||
|
|||||||
60
readeck/Data/Repository/OAuthRepository.swift
Normal file
60
readeck/Data/Repository/OAuthRepository.swift
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
//
|
||||||
|
// OAuthRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class OAuthRepository: POAuthRepository {
|
||||||
|
private let api: PAPI
|
||||||
|
private let logger = Logger.network
|
||||||
|
|
||||||
|
init(api: PAPI) {
|
||||||
|
self.api = api
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerClient(endpoint: String, clientName: String, redirectUri: String) async throws -> OAuthClient {
|
||||||
|
logger.info("Registering OAuth client: \(clientName)")
|
||||||
|
|
||||||
|
// Get app version for software_version
|
||||||
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
||||||
|
|
||||||
|
// Create unique software_id (could be stored in UserDefaults for consistency)
|
||||||
|
let softwareId = UUID().uuidString
|
||||||
|
|
||||||
|
let request = OAuthClientCreateDto(
|
||||||
|
clientName: clientName,
|
||||||
|
clientUri: "https://github.com/yourusername/readeck-ios", // TODO: Update with actual URL
|
||||||
|
softwareId: softwareId,
|
||||||
|
softwareVersion: appVersion,
|
||||||
|
redirectUris: [redirectUri],
|
||||||
|
grantTypes: ["authorization_code"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try await api.registerOAuthClient(endpoint: endpoint, request: request)
|
||||||
|
return OAuthClient(from: response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exchangeToken(
|
||||||
|
endpoint: String,
|
||||||
|
clientId: String,
|
||||||
|
code: String,
|
||||||
|
codeVerifier: String,
|
||||||
|
redirectUri: String
|
||||||
|
) async throws -> OAuthToken {
|
||||||
|
logger.info("Exchanging authorization code for access token")
|
||||||
|
|
||||||
|
let request = OAuthTokenRequestDto(
|
||||||
|
grantType: "authorization_code",
|
||||||
|
clientId: clientId,
|
||||||
|
code: code,
|
||||||
|
codeVerifier: codeVerifier,
|
||||||
|
redirectUri: redirectUri
|
||||||
|
)
|
||||||
|
|
||||||
|
let response = try await api.exchangeOAuthToken(endpoint: endpoint, request: request)
|
||||||
|
return OAuthToken(from: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@ class ServerInfoRepository: PServerInfoRepository {
|
|||||||
|
|
||||||
// Perform actual check
|
// Perform actual check
|
||||||
do {
|
do {
|
||||||
let info = try await apiClient.getServerInfo()
|
let info = try await apiClient.getServerInfo(endpoint: nil)
|
||||||
let serverInfo = ServerInfo(from: info)
|
let serverInfo = ServerInfo(from: info)
|
||||||
updateCache(serverInfo: serverInfo)
|
updateCache(serverInfo: serverInfo)
|
||||||
logger.info("Server reachability checked: true (version: \(info.version))")
|
logger.info("Server reachability checked: true (version: \(info.version))")
|
||||||
@ -51,23 +51,28 @@ class ServerInfoRepository: PServerInfoRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getServerInfo() async throws -> ServerInfo {
|
func getServerInfo(endpoint: String? = nil) async throws -> ServerInfo {
|
||||||
// Check cache first
|
// Check cache first (only if no custom endpoint provided)
|
||||||
if let cached = getCachedServerInfo() {
|
if endpoint == nil, let cached = getCachedServerInfo() {
|
||||||
logger.debug("Server info from cache")
|
logger.debug("Server info from cache")
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check rate limiting
|
// Check rate limiting (only if no custom endpoint provided)
|
||||||
if isRateLimited(), let cached = cachedServerInfo {
|
if endpoint == nil, isRateLimited(), let cached = cachedServerInfo {
|
||||||
logger.debug("Server info check rate limited, using cached value")
|
logger.debug("Server info check rate limited, using cached value")
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fresh info
|
// Fetch fresh info
|
||||||
let dto = try await apiClient.getServerInfo()
|
let dto = try await apiClient.getServerInfo(endpoint: endpoint)
|
||||||
let serverInfo = ServerInfo(from: dto)
|
let serverInfo = ServerInfo(from: dto)
|
||||||
updateCache(serverInfo: serverInfo)
|
|
||||||
|
// Only update cache if using default endpoint
|
||||||
|
if endpoint == nil {
|
||||||
|
updateCache(serverInfo: serverInfo)
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("Server info fetched: version \(dto.version)")
|
logger.info("Server info fetched: version \(dto.version)")
|
||||||
return serverInfo
|
return serverInfo
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,44 +4,105 @@ protocol TokenProvider {
|
|||||||
func getToken() async -> String?
|
func getToken() async -> String?
|
||||||
func getEndpoint() async -> String?
|
func getEndpoint() async -> String?
|
||||||
func setToken(_ token: String) async
|
func setToken(_ token: String) async
|
||||||
|
func setEndpoint(_ endpoint: String) async
|
||||||
func clearToken() async
|
func clearToken() async
|
||||||
|
|
||||||
|
// OAuth methods
|
||||||
|
func getOAuthToken() async -> OAuthToken?
|
||||||
|
func setOAuthToken(_ token: OAuthToken) async
|
||||||
|
func getAuthMethod() async -> AuthenticationMethod?
|
||||||
|
func setAuthMethod(_ method: AuthenticationMethod) async
|
||||||
}
|
}
|
||||||
|
|
||||||
class KeychainTokenProvider: TokenProvider {
|
class KeychainTokenProvider: TokenProvider {
|
||||||
private let keychainHelper = KeychainHelper.shared
|
private let keychainHelper = KeychainHelper.shared
|
||||||
|
|
||||||
// Cache to avoid repeated keychain access
|
// Cache to avoid repeated keychain access
|
||||||
private var cachedToken: String?
|
private var cachedToken: String?
|
||||||
private var cachedEndpoint: String?
|
private var cachedEndpoint: String?
|
||||||
|
private var cachedOAuthToken: OAuthToken?
|
||||||
|
private var cachedAuthMethod: AuthenticationMethod?
|
||||||
|
|
||||||
func getToken() async -> String? {
|
func getToken() async -> String? {
|
||||||
|
// Check auth method first
|
||||||
|
if let method = await getAuthMethod(), method == .oauth {
|
||||||
|
// Return OAuth access token if using OAuth
|
||||||
|
if let oauthToken = await getOAuthToken() {
|
||||||
|
return oauthToken.accessToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise return API token
|
||||||
if let cached = cachedToken {
|
if let cached = cachedToken {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
let token = keychainHelper.loadToken()
|
let token = keychainHelper.loadToken()
|
||||||
cachedToken = token
|
cachedToken = token
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEndpoint() async -> String? {
|
func getEndpoint() async -> String? {
|
||||||
if let cached = cachedEndpoint {
|
if let cached = cachedEndpoint {
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = keychainHelper.loadEndpoint()
|
let endpoint = keychainHelper.loadEndpoint()
|
||||||
cachedEndpoint = endpoint
|
cachedEndpoint = endpoint
|
||||||
return endpoint
|
return endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
func setToken(_ token: String) async {
|
func setToken(_ token: String) async {
|
||||||
keychainHelper.saveToken(token)
|
keychainHelper.saveToken(token)
|
||||||
|
keychainHelper.saveAuthMethod(.apiToken)
|
||||||
|
cachedAuthMethod = .apiToken
|
||||||
cachedToken = token
|
cachedToken = token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setEndpoint(_ endpoint: String) async {
|
||||||
|
keychainHelper.saveEndpoint(endpoint)
|
||||||
|
cachedEndpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
func clearToken() async {
|
func clearToken() async {
|
||||||
keychainHelper.clearCredentials()
|
keychainHelper.clearCredentials()
|
||||||
cachedToken = nil
|
cachedToken = nil
|
||||||
cachedEndpoint = nil
|
cachedEndpoint = nil
|
||||||
|
cachedOAuthToken = nil
|
||||||
|
cachedAuthMethod = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Methods
|
||||||
|
|
||||||
|
func getOAuthToken() async -> OAuthToken? {
|
||||||
|
if let cached = cachedOAuthToken {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = keychainHelper.loadOAuthToken()
|
||||||
|
cachedOAuthToken = token
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func setOAuthToken(_ token: OAuthToken) async {
|
||||||
|
keychainHelper.saveOAuthToken(token)
|
||||||
|
keychainHelper.saveAuthMethod(.oauth)
|
||||||
|
cachedOAuthToken = token
|
||||||
|
cachedAuthMethod = .oauth
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthMethod() async -> AuthenticationMethod? {
|
||||||
|
if let cached = cachedAuthMethod {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = keychainHelper.loadAuthMethod()
|
||||||
|
cachedAuthMethod = method
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAuthMethod(_ method: AuthenticationMethod) async {
|
||||||
|
keychainHelper.saveAuthMethod(method)
|
||||||
|
cachedAuthMethod = method
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,20 +2,26 @@ import Foundation
|
|||||||
|
|
||||||
struct ServerInfo {
|
struct ServerInfo {
|
||||||
let version: String
|
let version: String
|
||||||
let buildDate: String?
|
|
||||||
let userAgent: String?
|
|
||||||
let isReachable: Bool
|
let isReachable: Bool
|
||||||
|
let features: [String]?
|
||||||
|
|
||||||
|
var supportsOAuth: Bool {
|
||||||
|
features?.contains("oauth") ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsEmail: Bool {
|
||||||
|
features?.contains("email") ?? false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ServerInfo {
|
extension ServerInfo {
|
||||||
init(from dto: ServerInfoDto) {
|
init(from dto: ServerInfoDto) {
|
||||||
self.version = dto.version
|
self.version = dto.version.canonical
|
||||||
self.buildDate = dto.buildDate
|
self.features = dto.features
|
||||||
self.userAgent = dto.userAgent
|
|
||||||
self.isReachable = true
|
self.isReachable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
static var unreachable: ServerInfo {
|
static var unreachable: ServerInfo {
|
||||||
ServerInfo(version: "", buildDate: nil, userAgent: nil, isReachable: false)
|
ServerInfo(version: "", isReachable: false, features: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
readeck/Domain/Models/AuthenticationMethod.swift
Normal file
14
readeck/Domain/Models/AuthenticationMethod.swift
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// AuthenticationMethod.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Enum to distinguish between different authentication methods
|
||||||
|
enum AuthenticationMethod: String, Codable {
|
||||||
|
case apiToken = "api_token"
|
||||||
|
case oauth = "oauth"
|
||||||
|
}
|
||||||
27
readeck/Domain/Models/OAuthClient.swift
Normal file
27
readeck/Domain/Models/OAuthClient.swift
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// OAuthClient.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// OAuth client model for the domain layer
|
||||||
|
struct OAuthClient {
|
||||||
|
let clientId: String
|
||||||
|
let clientSecret: String?
|
||||||
|
let clientName: String
|
||||||
|
let redirectUris: [String]
|
||||||
|
let grantTypes: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthClient {
|
||||||
|
init(from dto: OAuthClientResponseDto) {
|
||||||
|
self.clientId = dto.clientId
|
||||||
|
self.clientSecret = dto.clientSecret
|
||||||
|
self.clientName = dto.clientName
|
||||||
|
self.redirectUris = dto.redirectUris
|
||||||
|
self.grantTypes = dto.grantTypes
|
||||||
|
}
|
||||||
|
}
|
||||||
35
readeck/Domain/Models/OAuthToken.swift
Normal file
35
readeck/Domain/Models/OAuthToken.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// OAuthToken.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// OAuth access token model for the domain layer
|
||||||
|
struct OAuthToken: Codable {
|
||||||
|
let accessToken: String
|
||||||
|
let tokenType: String
|
||||||
|
let scope: String?
|
||||||
|
let expiresIn: Int?
|
||||||
|
let refreshToken: String?
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
|
var isExpired: Bool {
|
||||||
|
guard let expiresIn = expiresIn else { return false }
|
||||||
|
let expiryDate = createdAt.addingTimeInterval(TimeInterval(expiresIn))
|
||||||
|
return Date() > expiryDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OAuthToken {
|
||||||
|
init(from dto: OAuthTokenResponseDto) {
|
||||||
|
self.accessToken = dto.accessToken
|
||||||
|
self.tokenType = dto.tokenType
|
||||||
|
self.scope = dto.scope
|
||||||
|
self.expiresIn = dto.expiresIn
|
||||||
|
self.refreshToken = dto.refreshToken
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,4 +10,8 @@ protocol PAuthRepository {
|
|||||||
func login(endpoint: String, username: String, password: String) async throws -> User
|
func login(endpoint: String, username: String, password: String) async throws -> User
|
||||||
func logout() async throws
|
func logout() async throws
|
||||||
func getCurrentSettings() async throws -> Settings?
|
func getCurrentSettings() async throws -> Settings?
|
||||||
|
|
||||||
|
func loginWithOAuth(endpoint: String, token: OAuthToken) async throws
|
||||||
|
func getAuthenticationMethod() async -> AuthenticationMethod?
|
||||||
|
func switchToClassicAuth(endpoint: String, username: String, password: String) async throws -> User
|
||||||
}
|
}
|
||||||
|
|||||||
28
readeck/Domain/Protocols/POAuthRepository.swift
Normal file
28
readeck/Domain/Protocols/POAuthRepository.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// POAuthRepository.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol POAuthRepository {
|
||||||
|
/// Registers an OAuth client with the server
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: Server endpoint URL
|
||||||
|
/// - clientName: Name of the client application
|
||||||
|
/// - redirectUri: Redirect URI for OAuth callback
|
||||||
|
/// - Returns: Registered OAuth client
|
||||||
|
func registerClient(endpoint: String, clientName: String, redirectUri: String) async throws -> OAuthClient
|
||||||
|
|
||||||
|
/// Exchanges an authorization code for an access token
|
||||||
|
/// - Parameters:
|
||||||
|
/// - endpoint: Server endpoint URL
|
||||||
|
/// - clientId: OAuth client ID
|
||||||
|
/// - code: Authorization code from OAuth callback
|
||||||
|
/// - codeVerifier: PKCE code verifier
|
||||||
|
/// - redirectUri: Redirect URI (must match the one used in authorization request)
|
||||||
|
/// - Returns: OAuth access token
|
||||||
|
func exchangeToken(endpoint: String, clientId: String, code: String, codeVerifier: String, redirectUri: String) async throws -> OAuthToken
|
||||||
|
}
|
||||||
@ -6,5 +6,5 @@
|
|||||||
|
|
||||||
protocol PServerInfoRepository {
|
protocol PServerInfoRepository {
|
||||||
func checkServerReachability() async -> Bool
|
func checkServerReachability() async -> Bool
|
||||||
func getServerInfo() async throws -> ServerInfo
|
func getServerInfo(endpoint: String?) async throws -> ServerInfo
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,6 @@ class CheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServerInfo() async throws -> ServerInfo {
|
func getServerInfo() async throws -> ServerInfo {
|
||||||
return try await repository.getServerInfo()
|
return try await repository.getServerInfo(endpoint: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
readeck/Domain/UseCase/GetServerInfoUseCase.swift
Normal file
23
readeck/Domain/UseCase/GetServerInfoUseCase.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// GetServerInfoUseCase.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PGetServerInfoUseCase {
|
||||||
|
func execute(endpoint: String?) async throws -> ServerInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetServerInfoUseCase: PGetServerInfoUseCase {
|
||||||
|
private let repository: PServerInfoRepository
|
||||||
|
|
||||||
|
init(repository: PServerInfoRepository) {
|
||||||
|
self.repository = repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(endpoint: String? = nil) async throws -> ServerInfo {
|
||||||
|
return try await repository.getServerInfo(endpoint: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
readeck/Domain/UseCase/GetUserProfileUseCase.swift
Normal file
25
readeck/Domain/UseCase/GetUserProfileUseCase.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// GetUserProfileUseCase.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Claude on 19.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol PGetUserProfileUseCase {
|
||||||
|
func execute() async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
class GetUserProfileUseCase: PGetUserProfileUseCase {
|
||||||
|
private let profileApiClient: PProfileApiClient
|
||||||
|
|
||||||
|
init(profileApiClient: PProfileApiClient) {
|
||||||
|
self.profileApiClient = profileApiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute() async throws -> String {
|
||||||
|
let profile = try await profileApiClient.getProfile()
|
||||||
|
return profile.user.username
|
||||||
|
}
|
||||||
|
}
|
||||||
21
readeck/Domain/UseCase/LoginWithOAuthUseCase.swift
Normal file
21
readeck/Domain/UseCase/LoginWithOAuthUseCase.swift
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS) && !APP_EXTENSION
|
||||||
|
|
||||||
|
protocol PLoginWithOAuthUseCase {
|
||||||
|
func execute(endpoint: String) async throws -> OAuthToken
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginWithOAuthUseCase: PLoginWithOAuthUseCase {
|
||||||
|
private let oauthCoordinator: OAuthFlowCoordinator
|
||||||
|
|
||||||
|
init(oauthCoordinator: OAuthFlowCoordinator) {
|
||||||
|
self.oauthCoordinator = oauthCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(endpoint: String) async throws -> OAuthToken {
|
||||||
|
return try await oauthCoordinator.executeOAuthFlow(endpoint: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@ -223,7 +223,6 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
#if DEBUG
|
|
||||||
// Toggle button (left)
|
// Toggle button (left)
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
if #available(iOS 26.0, *) {
|
if #available(iOS 26.0, *) {
|
||||||
@ -235,7 +234,6 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
// Top toolbar (right)
|
// Top toolbar (right)
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
// Created by Ilyas Hallak on 21.11.25.
|
// Created by Ilyas Hallak on 21.11.25.
|
||||||
//
|
//
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import netfox
|
||||||
|
|
||||||
struct DebugMenuView: View {
|
struct DebugMenuView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ -20,10 +20,56 @@ struct DebugMenuView: View {
|
|||||||
Section {
|
Section {
|
||||||
networkSimulationToggle
|
networkSimulationToggle
|
||||||
networkStatusInfo
|
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: {
|
} header: {
|
||||||
Text("Network Debugging")
|
Text("Network Debugging")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Simulate offline mode to test offline reading features")
|
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
|
// MARK: - Offline Debugging Section
|
||||||
@ -49,25 +95,6 @@ struct DebugMenuView: View {
|
|||||||
Text("Select a cached bookmark to diagnose offline image issues")
|
Text("Select a cached bookmark to diagnose offline image issues")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Logging Section
|
|
||||||
Section {
|
|
||||||
NavigationLink {
|
|
||||||
DebugLogViewer()
|
|
||||||
} label: {
|
|
||||||
Label("View Logs", systemImage: "doc.text.magnifyingglass")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(role: .destructive) {
|
|
||||||
viewModel.clearLogs()
|
|
||||||
} label: {
|
|
||||||
Label("Clear All Logs", systemImage: "trash")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Logging")
|
|
||||||
} footer: {
|
|
||||||
Text("View and manage application logs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Data Section
|
// MARK: - Data Section
|
||||||
Section {
|
Section {
|
||||||
cacheInfo
|
cacheInfo
|
||||||
@ -90,6 +117,23 @@ struct DebugMenuView: View {
|
|||||||
Text("⚠️ Reset Core Data will delete all local bookmarks and cache")
|
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
|
// MARK: - App Info Section
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
@ -113,6 +157,14 @@ struct DebugMenuView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Build Type")
|
||||||
|
Spacer()
|
||||||
|
Text(viewModel.buildType)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("App Information")
|
Text("App Information")
|
||||||
}
|
}
|
||||||
@ -128,6 +180,7 @@ struct DebugMenuView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadCacheInfo()
|
await viewModel.loadCacheInfo()
|
||||||
|
viewModel.checkNetFoxStatus()
|
||||||
}
|
}
|
||||||
.alert("Clear Offline Cache?", isPresented: $viewModel.showResetCacheAlert) {
|
.alert("Clear Offline Cache?", isPresented: $viewModel.showResetCacheAlert) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
@ -216,10 +269,13 @@ class DebugMenuViewModel: ObservableObject {
|
|||||||
@Published var cacheSize = "0 KB"
|
@Published var cacheSize = "0 KB"
|
||||||
@Published var selectedBookmarkId: String?
|
@Published var selectedBookmarkId: String?
|
||||||
@Published var cachedBookmarks: [Bookmark] = []
|
@Published var cachedBookmarks: [Bookmark] = []
|
||||||
|
@Published var isLoggingEnabled = false
|
||||||
|
@Published var isNetFoxRunning = false
|
||||||
|
|
||||||
private let offlineCacheRepository = OfflineCacheRepository()
|
private let offlineCacheRepository = OfflineCacheRepository()
|
||||||
private let coreDataManager = CoreDataManager.shared
|
private let coreDataManager = CoreDataManager.shared
|
||||||
private let logger = Logger.general
|
private let logger = Logger.general
|
||||||
|
private let logConfig = LogConfiguration.shared
|
||||||
|
|
||||||
var appVersion: String {
|
var appVersion: String {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
||||||
@ -233,6 +289,33 @@ class DebugMenuViewModel: ObservableObject {
|
|||||||
Bundle.main.bundleIdentifier ?? "Unknown"
|
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 {
|
func loadCacheInfo() async {
|
||||||
cachedArticlesCount = offlineCacheRepository.getCachedArticlesCount()
|
cachedArticlesCount = offlineCacheRepository.getCachedArticlesCount()
|
||||||
cacheSize = offlineCacheRepository.getCacheSize()
|
cacheSize = offlineCacheRepository.getCacheSize()
|
||||||
@ -260,8 +343,10 @@ class DebugMenuViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clearLogs() {
|
func clearLogs() {
|
||||||
// TODO: Implement log clearing when we add persistent logging
|
Task {
|
||||||
logger.info("Logs cleared via Debug Menu")
|
await LogStore.shared.clear()
|
||||||
|
logger.info("Logs cleared via Debug Menu")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetCoreData() {
|
func resetCoreData() {
|
||||||
@ -315,4 +400,3 @@ extension View {
|
|||||||
DebugMenuView()
|
DebugMenuView()
|
||||||
.environmentObject(AppSettings())
|
.environmentObject(AppSettings())
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ protocol UseCaseFactory {
|
|||||||
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase
|
||||||
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase
|
||||||
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
func makeCheckServerReachabilityUseCase() -> PCheckServerReachabilityUseCase
|
||||||
|
func makeGetServerInfoUseCase() -> PGetServerInfoUseCase
|
||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase
|
||||||
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
|
||||||
func makeSettingsRepository() -> PSettingsRepository
|
func makeSettingsRepository() -> PSettingsRepository
|
||||||
@ -35,6 +36,8 @@ protocol UseCaseFactory {
|
|||||||
func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase
|
func makeGetMaxCacheSizeUseCase() -> PGetMaxCacheSizeUseCase
|
||||||
func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase
|
func makeUpdateMaxCacheSizeUseCase() -> PUpdateMaxCacheSizeUseCase
|
||||||
func makeClearCacheUseCase() -> PClearCacheUseCase
|
func makeClearCacheUseCase() -> PClearCacheUseCase
|
||||||
|
func makeLoginWithOAuthUseCase() -> PLoginWithOAuthUseCase
|
||||||
|
func makeAuthRepository() -> PAuthRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -42,7 +45,9 @@ protocol UseCaseFactory {
|
|||||||
class DefaultUseCaseFactory: UseCaseFactory {
|
class DefaultUseCaseFactory: UseCaseFactory {
|
||||||
private let tokenProvider = KeychainTokenProvider()
|
private let tokenProvider = KeychainTokenProvider()
|
||||||
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
||||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository)
|
private lazy var profileApiClient: PProfileApiClient = ProfileApiClient(tokenProvider: tokenProvider)
|
||||||
|
private lazy var getUserProfileUseCase: PGetUserProfileUseCase = GetUserProfileUseCase(profileApiClient: profileApiClient)
|
||||||
|
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository, getUserProfileUseCase: getUserProfileUseCase)
|
||||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||||
private let settingsRepository: PSettingsRepository = SettingsRepository()
|
private let settingsRepository: PSettingsRepository = SettingsRepository()
|
||||||
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
|
private lazy var infoApiClient: PInfoApiClient = InfoApiClient(tokenProvider: tokenProvider)
|
||||||
@ -149,6 +154,10 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
|
return CheckServerReachabilityUseCase(repository: serverInfoRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeGetServerInfoUseCase() -> PGetServerInfoUseCase {
|
||||||
|
return GetServerInfoUseCase(repository: serverInfoRepository)
|
||||||
|
}
|
||||||
|
|
||||||
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
func makeGetBookmarkAnnotationsUseCase() -> PGetBookmarkAnnotationsUseCase {
|
||||||
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
return GetBookmarkAnnotationsUseCase(repository: annotationsRepository)
|
||||||
}
|
}
|
||||||
@ -200,4 +209,16 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
|||||||
func makeClearCacheUseCase() -> PClearCacheUseCase {
|
func makeClearCacheUseCase() -> PClearCacheUseCase {
|
||||||
return ClearCacheUseCase(settingsRepository: settingsRepository)
|
return ClearCacheUseCase(settingsRepository: settingsRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lazy var oauthRepository: POAuthRepository = OAuthRepository(api: api)
|
||||||
|
private lazy var oauthManager: OAuthManager = OAuthManager(repository: oauthRepository)
|
||||||
|
|
||||||
|
@MainActor func makeLoginWithOAuthUseCase() -> PLoginWithOAuthUseCase {
|
||||||
|
let coordinator = OAuthFlowCoordinator(manager: oauthManager)
|
||||||
|
return LoginWithOAuthUseCase(oauthCoordinator: coordinator)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAuthRepository() -> PAuthRepository {
|
||||||
|
return authRepository
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,11 @@ class MockUseCaseFactory: UseCaseFactory {
|
|||||||
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
func makeCheckServerReachabilityUseCase() -> any PCheckServerReachabilityUseCase {
|
||||||
MockCheckServerReachabilityUseCase()
|
MockCheckServerReachabilityUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func makeGetServerInfoUseCase() -> any PGetServerInfoUseCase {
|
||||||
|
MockGetServerInfoUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase {
|
||||||
MockOfflineBookmarkSyncUseCase()
|
MockOfflineBookmarkSyncUseCase()
|
||||||
}
|
}
|
||||||
@ -302,7 +306,13 @@ class MockCheckServerReachabilityUseCase: PCheckServerReachabilityUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServerInfo() async throws -> ServerInfo {
|
func getServerInfo() async throws -> ServerInfo {
|
||||||
return ServerInfo(version: "1.0.0", buildDate: nil, userAgent: nil, isReachable: true)
|
return ServerInfo(version: "1.0.0", isReachable: true, features: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockGetServerInfoUseCase: PGetServerInfoUseCase {
|
||||||
|
func execute(endpoint: String? = nil) async throws -> ServerInfo {
|
||||||
|
return ServerInfo(version: "1.0.0", isReachable: true, features: ["oauth"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -456,3 +466,51 @@ class MockUpdateMaxCacheSizeUseCase: PUpdateMaxCacheSizeUseCase {
|
|||||||
class MockClearCacheUseCase: PClearCacheUseCase {
|
class MockClearCacheUseCase: PClearCacheUseCase {
|
||||||
func execute() async throws {}
|
func execute() async throws {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - OAuth Mock Extensions
|
||||||
|
extension MockUseCaseFactory {
|
||||||
|
func makeLoginWithOAuthUseCase() -> PLoginWithOAuthUseCase {
|
||||||
|
MockLoginWithOAuthUseCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeAuthRepository() -> PAuthRepository {
|
||||||
|
MockAuthRepository()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockLoginWithOAuthUseCase: PLoginWithOAuthUseCase {
|
||||||
|
func execute(endpoint: String) async throws -> OAuthToken {
|
||||||
|
return OAuthToken(
|
||||||
|
accessToken: "mock_access_token",
|
||||||
|
tokenType: "Bearer",
|
||||||
|
scope: "read write",
|
||||||
|
expiresIn: 3600,
|
||||||
|
refreshToken: "mock_refresh_token",
|
||||||
|
createdAt: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockAuthRepository: PAuthRepository {
|
||||||
|
func login(endpoint: String, username: String, password: String) async throws -> User {
|
||||||
|
return User(id: "mock_user", token: "mock_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logout() async throws {}
|
||||||
|
|
||||||
|
func getCurrentSettings() async throws -> Settings? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginWithOAuth(endpoint: String, token: OAuthToken) async throws {
|
||||||
|
// Mock: No need to fetch profile in mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAuthenticationMethod() async -> AuthenticationMethod? {
|
||||||
|
return .apiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToClassicAuth(endpoint: String, username: String, password: String) async throws -> User {
|
||||||
|
return User(id: "mock_user", token: "mock_token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -9,13 +9,45 @@ import SwiftUI
|
|||||||
|
|
||||||
struct OnboardingServerView: View {
|
struct OnboardingServerView: View {
|
||||||
@State private var viewModel = SettingsServerViewModel()
|
@State private var viewModel = SettingsServerViewModel()
|
||||||
|
@State private var showLoginFields = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
classicLoginForm
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonEnabled: Bool {
|
||||||
|
if showLoginFields {
|
||||||
|
// Phase 2: Need endpoint, username, and password
|
||||||
|
return !viewModel.endpoint.isEmpty && !viewModel.username.isEmpty && !viewModel.password.isEmpty
|
||||||
|
} else {
|
||||||
|
// Phase 1: Only need endpoint
|
||||||
|
return !viewModel.endpoint.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var classicLoginForm: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
SectionHeader(title: "Server Settings".localized, icon: "server.rack")
|
// Readeck Logo with green background
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color("green"))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image("readeck")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
|
||||||
|
Text(showLoginFields ? "Enter your credentials" : "Enter your Readeck server")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.bold)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
|
||||||
Text("Enter your Readeck server details to get started.")
|
Text(showLoginFields ? "Please provide your username and password." : "Enter your server endpoint to get started.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -73,28 +105,31 @@ struct OnboardingServerView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username
|
// Username & Password - only show when showLoginFields is true
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if showLoginFields {
|
||||||
TextField("",
|
// Username
|
||||||
text: $viewModel.username,
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
prompt: Text("Username").foregroundColor(.secondary))
|
TextField("",
|
||||||
.textFieldStyle(.roundedBorder)
|
text: $viewModel.username,
|
||||||
.autocapitalization(.none)
|
prompt: Text("Username").foregroundColor(.secondary))
|
||||||
.disableAutocorrection(true)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onChange(of: viewModel.username) {
|
.autocapitalization(.none)
|
||||||
viewModel.clearMessages()
|
.disableAutocorrection(true)
|
||||||
}
|
.onChange(of: viewModel.username) {
|
||||||
}
|
viewModel.clearMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Password
|
// Password
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
SecureField("",
|
SecureField("",
|
||||||
text: $viewModel.password,
|
text: $viewModel.password,
|
||||||
prompt: Text("Password").foregroundColor(.secondary))
|
prompt: Text("Password").foregroundColor(.secondary))
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.onChange(of: viewModel.password) {
|
.onChange(of: viewModel.password) {
|
||||||
viewModel.clearMessages()
|
viewModel.clearMessages()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +157,24 @@ struct OnboardingServerView: View {
|
|||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
Task {
|
Task {
|
||||||
await viewModel.saveServerSettings()
|
if !showLoginFields {
|
||||||
|
// Phase 1: Check server for OAuth support
|
||||||
|
await viewModel.checkServerOAuthSupport()
|
||||||
|
if viewModel.serverSupportsOAuth {
|
||||||
|
// Try OAuth login
|
||||||
|
await viewModel.loginWithOAuth()
|
||||||
|
// If OAuth fails, error message is shown, user can fallback to classic
|
||||||
|
if viewModel.errorMessage != nil {
|
||||||
|
showLoginFields = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No OAuth → show login fields for classic auth
|
||||||
|
showLoginFields = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Phase 2: Classic login
|
||||||
|
await viewModel.saveServerSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -131,16 +183,16 @@ struct OnboardingServerView: View {
|
|||||||
.scaleEffect(0.8)
|
.scaleEffect(0.8)
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||||
}
|
}
|
||||||
Text(viewModel.isLoading ? "Saving..." : (viewModel.isLoggedIn ? "Re-login & Save" : "Login & Save"))
|
Text(viewModel.isLoading ? (showLoginFields ? "Logging in..." : "Checking...") : (showLoginFields ? "Login & Save" : "Continue"))
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
.background(buttonEnabled ? Color.accentColor : Color.gray)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
}
|
}
|
||||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
.disabled(!buttonEnabled || viewModel.isLoading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
|
|||||||
@ -13,7 +13,7 @@ struct DebugLogViewer: View {
|
|||||||
@State private var selectedCategory: LogCategory?
|
@State private var selectedCategory: LogCategory?
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@State private var showShareSheet = false
|
@State private var showShareSheet = false
|
||||||
@State private var exportText = ""
|
@State private var exportURL: URL?
|
||||||
@State private var autoScroll = true
|
@State private var autoScroll = true
|
||||||
@State private var showFilters = false
|
@State private var showFilters = false
|
||||||
@StateObject private var logConfig = LogConfiguration.shared
|
@StateObject private var logConfig = LogConfiguration.shared
|
||||||
@ -113,7 +113,9 @@ struct DebugLogViewer: View {
|
|||||||
await refreshLogs()
|
await refreshLogs()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showShareSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
ActivityView(activityItems: [exportText])
|
if let url = exportURL {
|
||||||
|
ActivityView(activityItems: [url])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,9 +331,21 @@ struct DebugLogViewer: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func exportLogs() async {
|
private func exportLogs() async {
|
||||||
exportText = await LogStore.shared.exportAsText()
|
do {
|
||||||
showShareSheet = true
|
let (zipData, filename) = try await LogStore.shared.exportAsZippedData()
|
||||||
logger.info("Exported debug logs")
|
|
||||||
|
// Write to temporary file
|
||||||
|
let tempDir = FileManager.default.temporaryDirectory
|
||||||
|
let fileURL = tempDir.appendingPathComponent(filename)
|
||||||
|
|
||||||
|
try zipData.write(to: fileURL)
|
||||||
|
|
||||||
|
exportURL = fileURL
|
||||||
|
showShareSheet = true
|
||||||
|
logger.info("Exported debug logs as ZIP: \(filename)")
|
||||||
|
} catch {
|
||||||
|
logger.error("Failed to export logs: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func levelName(for level: LogLevel) -> String {
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
struct FontDebugView: View {
|
struct FontDebugView: View {
|
||||||
@State private var availableFonts: [String: [String]] = [:]
|
@State private var availableFonts: [String: [String]] = [:]
|
||||||
|
|
||||||
@ -68,4 +67,3 @@ struct FontDebugView: View {
|
|||||||
#Preview {
|
#Preview {
|
||||||
FontDebugView()
|
FontDebugView()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|||||||
@ -4,34 +4,43 @@ import SwiftUI
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
class SettingsServerViewModel {
|
class SettingsServerViewModel {
|
||||||
|
|
||||||
// MARK: - Use Cases
|
// MARK: - Use Cases
|
||||||
|
|
||||||
private let loginUseCase: PLoginUseCase
|
private let loginUseCase: PLoginUseCase
|
||||||
private let logoutUseCase: PLogoutUseCase
|
private let logoutUseCase: PLogoutUseCase
|
||||||
private let saveServerSettingsUseCase: PSaveServerSettingsUseCase
|
private let saveServerSettingsUseCase: PSaveServerSettingsUseCase
|
||||||
private let loadSettingsUseCase: PLoadSettingsUseCase
|
private let loadSettingsUseCase: PLoadSettingsUseCase
|
||||||
|
private let getServerInfoUseCase: PGetServerInfoUseCase
|
||||||
|
private let loginWithOAuthUseCase: PLoginWithOAuthUseCase
|
||||||
|
private let authRepository: PAuthRepository
|
||||||
|
|
||||||
// MARK: - Server Settings
|
// MARK: - Server Settings
|
||||||
var endpoint = ""
|
var endpoint = ""
|
||||||
var username = ""
|
var username = ""
|
||||||
var password = ""
|
var password = ""
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isLoggedIn = false
|
var isLoggedIn = false
|
||||||
|
|
||||||
|
// MARK: - OAuth Support
|
||||||
|
var serverSupportsOAuth = false
|
||||||
|
|
||||||
// MARK: - Messages
|
// MARK: - Messages
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var successMessage: String?
|
var successMessage: String?
|
||||||
|
|
||||||
private var hasFinishedSetup: Bool {
|
private var hasFinishedSetup: Bool {
|
||||||
SettingsRepository().hasFinishedSetup
|
SettingsRepository().hasFinishedSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
|
||||||
self.loginUseCase = factory.makeLoginUseCase()
|
self.loginUseCase = factory.makeLoginUseCase()
|
||||||
self.logoutUseCase = factory.makeLogoutUseCase()
|
self.logoutUseCase = factory.makeLogoutUseCase()
|
||||||
self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase()
|
self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase()
|
||||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||||
|
self.getServerInfoUseCase = factory.makeGetServerInfoUseCase()
|
||||||
|
self.loginWithOAuthUseCase = factory.makeLoginWithOAuthUseCase()
|
||||||
|
self.authRepository = factory.makeAuthRepository()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isSetupMode: Bool {
|
var isSetupMode: Bool {
|
||||||
@ -101,4 +110,54 @@ class SettingsServerViewModel {
|
|||||||
var canLogin: Bool {
|
var canLogin: Bool {
|
||||||
!username.isEmpty && !password.isEmpty
|
!username.isEmpty && !password.isEmpty
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: - OAuth Methods
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func checkServerOAuthSupport() async {
|
||||||
|
guard !endpoint.isEmpty else {
|
||||||
|
serverSupportsOAuth = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let serverInfo = try await getServerInfoUseCase.execute(endpoint: normalizedEndpoint)
|
||||||
|
serverSupportsOAuth = serverInfo.supportsOAuth
|
||||||
|
} catch {
|
||||||
|
serverSupportsOAuth = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func loginWithOAuth() async {
|
||||||
|
guard !endpoint.isEmpty else {
|
||||||
|
errorMessage = "Please enter a server endpoint."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages()
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let normalizedEndpoint = EndpointValidator.normalize(endpoint)
|
||||||
|
let token = try await loginWithOAuthUseCase.execute(endpoint: normalizedEndpoint)
|
||||||
|
|
||||||
|
// Save OAuth token and mark as logged in
|
||||||
|
try await authRepository.loginWithOAuth(endpoint: normalizedEndpoint, token: token)
|
||||||
|
|
||||||
|
// Update local endpoint with normalized version
|
||||||
|
endpoint = normalizedEndpoint
|
||||||
|
|
||||||
|
isLoggedIn = true
|
||||||
|
successMessage = "Successfully logged in with OAuth."
|
||||||
|
try await SettingsRepository().saveHasFinishedSetup(true)
|
||||||
|
NotificationCenter.default.post(name: .setupStatusChanged, object: nil)
|
||||||
|
} catch {
|
||||||
|
errorMessage = "OAuth login failed: \(error.localizedDescription)"
|
||||||
|
isLoggedIn = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
45
readeck/UI/Utils/BuildEnvironment.swift
Normal file
45
readeck/UI/Utils/BuildEnvironment.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// BuildEnvironment.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Build Environment Detection
|
||||||
|
|
||||||
|
extension Bundle {
|
||||||
|
/// Returns true if running in DEBUG build (Xcode development)
|
||||||
|
var isDebugBuild: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if running in TestFlight (beta distribution)
|
||||||
|
var isTestFlightBuild: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return false
|
||||||
|
#else
|
||||||
|
guard let path = self.appStoreReceiptURL?.path else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return path.contains("sandboxReceipt")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if running in Production (App Store release)
|
||||||
|
var isProduction: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return false
|
||||||
|
#else
|
||||||
|
guard let path = self.appStoreReceiptURL?.path else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !path.contains("sandboxReceipt")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
323
readeck/UI/Utils/LogStore.swift
Normal file
323
readeck/UI/Utils/LogStore.swift
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// LogStore.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
// MARK: - Log Entry
|
||||||
|
|
||||||
|
struct LogEntry: Identifiable, Codable {
|
||||||
|
let id: UUID
|
||||||
|
let timestamp: Date
|
||||||
|
let level: LogLevel
|
||||||
|
let category: LogCategory
|
||||||
|
let message: String
|
||||||
|
let file: String
|
||||||
|
let function: String
|
||||||
|
let line: Int
|
||||||
|
|
||||||
|
var fileName: String {
|
||||||
|
URL(fileURLWithPath: file).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedTimestamp: String {
|
||||||
|
DateFormatter.logTimestamp.string(from: timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
timestamp: Date = Date(),
|
||||||
|
level: LogLevel,
|
||||||
|
category: LogCategory,
|
||||||
|
message: String,
|
||||||
|
file: String,
|
||||||
|
function: String,
|
||||||
|
line: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.level = level
|
||||||
|
self.category = category
|
||||||
|
self.message = message
|
||||||
|
self.file = file
|
||||||
|
self.function = function
|
||||||
|
self.line = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Store
|
||||||
|
|
||||||
|
actor LogStore {
|
||||||
|
static let shared = LogStore()
|
||||||
|
|
||||||
|
private var entries: [LogEntry] = []
|
||||||
|
private let maxEntries: Int
|
||||||
|
|
||||||
|
private init(maxEntries: Int = 1000) {
|
||||||
|
self.maxEntries = maxEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ entry: LogEntry) {
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
// Keep only the most recent entries
|
||||||
|
if entries.count > maxEntries {
|
||||||
|
entries.removeFirst(entries.count - maxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries() -> [LogEntry] {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEntries(
|
||||||
|
level: LogLevel? = nil,
|
||||||
|
category: LogCategory? = nil,
|
||||||
|
searchText: String? = nil
|
||||||
|
) -> [LogEntry] {
|
||||||
|
var filtered = entries
|
||||||
|
|
||||||
|
if let level = level {
|
||||||
|
filtered = filtered.filter { $0.level == level }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let category = category {
|
||||||
|
filtered = filtered.filter { $0.category == category }
|
||||||
|
}
|
||||||
|
|
||||||
|
if let searchText = searchText, !searchText.isEmpty {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
$0.message.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.fileName.localizedCaseInsensitiveContains(searchText) ||
|
||||||
|
$0.function.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
entries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsText() -> String {
|
||||||
|
var text = "Readeck Debug Logs\n"
|
||||||
|
text += "Generated: \(DateFormatter.exportTimestamp.string(from: Date()))\n"
|
||||||
|
text += "Total Entries: \(entries.count)\n"
|
||||||
|
text += String(repeating: "=", count: 80) + "\n\n"
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
text += "[\(entry.formattedTimestamp)] "
|
||||||
|
text += "[\(entry.level.emoji) \(levelName(for: entry.level))] "
|
||||||
|
text += "[\(entry.category.rawValue)] "
|
||||||
|
text += "\(entry.fileName):\(entry.line) "
|
||||||
|
text += "\(entry.function)\n"
|
||||||
|
text += " \(entry.message)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportAsZippedData() throws -> (data: Data, filename: String) {
|
||||||
|
// Generate log text
|
||||||
|
let logText = exportAsText()
|
||||||
|
guard let logData = logText.data(using: .utf8) else {
|
||||||
|
throw NSError(domain: "LogStore", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert logs to UTF-8"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create filename with timestamp
|
||||||
|
let timestamp = DateFormatter.filenameTimestamp.string(from: Date())
|
||||||
|
let filename = "readeck-logs-\(timestamp).zip"
|
||||||
|
let logFilename = "readeck-logs-\(timestamp).txt"
|
||||||
|
|
||||||
|
// Create ZIP archive
|
||||||
|
let zipData = try createZipArchive(filename: logFilename, data: logData)
|
||||||
|
|
||||||
|
return (zipData, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createZipArchive(filename: String, data: Data) throws -> Data {
|
||||||
|
// Create a simple ZIP file structure
|
||||||
|
// ZIP file format: https://en.wikipedia.org/wiki/ZIP_(file_format)
|
||||||
|
|
||||||
|
let crc32 = calculateCRC32(data: data)
|
||||||
|
let compressedData = try compressData(data)
|
||||||
|
|
||||||
|
var zipData = Data()
|
||||||
|
|
||||||
|
// Local file header
|
||||||
|
zipData.append(contentsOf: [0x50, 0x4B, 0x03, 0x04]) // Local file header signature
|
||||||
|
zipData.append(contentsOf: [0x14, 0x00]) // Version needed to extract (2.0)
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // General purpose bit flag
|
||||||
|
zipData.append(contentsOf: [0x08, 0x00]) // Compression method (deflate)
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // File last modification time
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // File last modification date
|
||||||
|
zipData.append(contentsOf: UInt32(crc32).littleEndianBytes) // CRC-32
|
||||||
|
zipData.append(contentsOf: UInt32(compressedData.count).littleEndianBytes) // Compressed size
|
||||||
|
zipData.append(contentsOf: UInt32(data.count).littleEndianBytes) // Uncompressed size
|
||||||
|
zipData.append(contentsOf: UInt16(filename.utf8.count).littleEndianBytes) // File name length
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Extra field length
|
||||||
|
zipData.append(contentsOf: filename.utf8) // File name
|
||||||
|
zipData.append(compressedData) // Compressed data
|
||||||
|
|
||||||
|
let localHeaderSize = zipData.count
|
||||||
|
|
||||||
|
// Central directory header
|
||||||
|
let centralDirStart = zipData.count
|
||||||
|
zipData.append(contentsOf: [0x50, 0x4B, 0x01, 0x02]) // Central directory file header signature
|
||||||
|
zipData.append(contentsOf: [0x14, 0x00]) // Version made by
|
||||||
|
zipData.append(contentsOf: [0x14, 0x00]) // Version needed to extract
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // General purpose bit flag
|
||||||
|
zipData.append(contentsOf: [0x08, 0x00]) // Compression method
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // File last modification time
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // File last modification date
|
||||||
|
zipData.append(contentsOf: UInt32(crc32).littleEndianBytes) // CRC-32
|
||||||
|
zipData.append(contentsOf: UInt32(compressedData.count).littleEndianBytes) // Compressed size
|
||||||
|
zipData.append(contentsOf: UInt32(data.count).littleEndianBytes) // Uncompressed size
|
||||||
|
zipData.append(contentsOf: UInt16(filename.utf8.count).littleEndianBytes) // File name length
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Extra field length
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // File comment length
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Disk number start
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Internal file attributes
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // External file attributes
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) // Relative offset of local header
|
||||||
|
zipData.append(contentsOf: filename.utf8) // File name
|
||||||
|
|
||||||
|
let centralDirSize = zipData.count - centralDirStart
|
||||||
|
|
||||||
|
// End of central directory record
|
||||||
|
zipData.append(contentsOf: [0x50, 0x4B, 0x05, 0x06]) // End of central directory signature
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Number of this disk
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Disk where central directory starts
|
||||||
|
zipData.append(contentsOf: [0x01, 0x00]) // Number of central directory records on this disk
|
||||||
|
zipData.append(contentsOf: [0x01, 0x00]) // Total number of central directory records
|
||||||
|
zipData.append(contentsOf: UInt32(centralDirSize).littleEndianBytes) // Size of central directory
|
||||||
|
zipData.append(contentsOf: UInt32(centralDirStart).littleEndianBytes) // Offset of start of central directory
|
||||||
|
zipData.append(contentsOf: [0x00, 0x00]) // Comment length
|
||||||
|
|
||||||
|
return zipData
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compressData(_ data: Data) throws -> Data {
|
||||||
|
var compressedData = Data()
|
||||||
|
let bufferSize = 4096
|
||||||
|
|
||||||
|
try data.withUnsafeBytes { (sourceBytes: UnsafeRawBufferPointer) in
|
||||||
|
guard let sourcePointer = sourceBytes.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||||
|
throw NSError(domain: "LogStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to get source pointer"])
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||||
|
defer { destinationBuffer.deallocate() }
|
||||||
|
|
||||||
|
let streamPtr = UnsafeMutablePointer<compression_stream>.allocate(capacity: 1)
|
||||||
|
defer { streamPtr.deallocate() }
|
||||||
|
|
||||||
|
var stream = streamPtr.pointee
|
||||||
|
var status = compression_stream_init(&stream, COMPRESSION_STREAM_ENCODE, COMPRESSION_ZLIB)
|
||||||
|
guard status == COMPRESSION_STATUS_OK else {
|
||||||
|
throw NSError(domain: "LogStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to initialize compression stream"])
|
||||||
|
}
|
||||||
|
defer { compression_stream_destroy(&stream) }
|
||||||
|
|
||||||
|
stream.src_ptr = sourcePointer
|
||||||
|
stream.src_size = data.count
|
||||||
|
stream.dst_ptr = destinationBuffer
|
||||||
|
stream.dst_size = bufferSize
|
||||||
|
|
||||||
|
while status == COMPRESSION_STATUS_OK {
|
||||||
|
status = compression_stream_process(&stream, Int32(COMPRESSION_STREAM_FINALIZE.rawValue))
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
|
||||||
|
let bytesWritten = bufferSize - stream.dst_size
|
||||||
|
compressedData.append(destinationBuffer, count: bytesWritten)
|
||||||
|
stream.dst_ptr = destinationBuffer
|
||||||
|
stream.dst_size = bufferSize
|
||||||
|
case COMPRESSION_STATUS_ERROR:
|
||||||
|
throw NSError(domain: "LogStore", code: 4, userInfo: [NSLocalizedDescriptionKey: "Compression failed"])
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compressedData
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateCRC32(data: Data) -> UInt32 {
|
||||||
|
var crc: UInt32 = 0xFFFFFFFF
|
||||||
|
|
||||||
|
for byte in data {
|
||||||
|
let index = Int((crc ^ UInt32(byte)) & 0xFF)
|
||||||
|
crc = (crc >> 8) ^ crc32Table[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
return crc ^ 0xFFFFFFFF
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC-32 lookup table
|
||||||
|
private var crc32Table: [UInt32] {
|
||||||
|
return (0..<256).map { i -> UInt32 in
|
||||||
|
var crc = UInt32(i)
|
||||||
|
for _ in 0..<8 {
|
||||||
|
crc = (crc & 1 == 1) ? ((crc >> 1) ^ 0xEDB88320) : (crc >> 1)
|
||||||
|
}
|
||||||
|
return crc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func levelName(for level: LogLevel) -> String {
|
||||||
|
switch level.rawValue {
|
||||||
|
case 0: return "DEBUG"
|
||||||
|
case 1: return "INFO"
|
||||||
|
case 2: return "NOTICE"
|
||||||
|
case 3: return "WARNING"
|
||||||
|
case 4: return "ERROR"
|
||||||
|
case 5: return "CRITICAL"
|
||||||
|
default: return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFormatter Extension
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let exportTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let filenameTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper Extensions for ZIP
|
||||||
|
|
||||||
|
extension UInt32 {
|
||||||
|
var littleEndianBytes: [UInt8] {
|
||||||
|
return [
|
||||||
|
UInt8(self & 0xFF),
|
||||||
|
UInt8((self >> 8) & 0xFF),
|
||||||
|
UInt8((self >> 16) & 0xFF),
|
||||||
|
UInt8((self >> 24) & 0xFF)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UInt16 {
|
||||||
|
var littleEndianBytes: [UInt8] {
|
||||||
|
return [
|
||||||
|
UInt8(self & 0xFF),
|
||||||
|
UInt8((self >> 8) & 0xFF)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
readeck/UI/Utils/VersionManager.swift
Normal file
38
readeck/UI/Utils/VersionManager.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class VersionManager {
|
||||||
|
static let shared = VersionManager()
|
||||||
|
|
||||||
|
private let lastSeenVersionKey = "lastSeenAppVersion"
|
||||||
|
private let userDefaults = UserDefaults.standard
|
||||||
|
|
||||||
|
var currentVersion: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBuild: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullVersion: String {
|
||||||
|
"\(currentVersion) (\(currentBuild))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastSeenVersion: String? {
|
||||||
|
userDefaults.string(forKey: lastSeenVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNewVersion: Bool {
|
||||||
|
guard let lastSeen = lastSeenVersion else {
|
||||||
|
// First launch
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return lastSeen != currentVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
func markVersionAsSeen() {
|
||||||
|
userDefaults.set(currentVersion, forKey: lastSeenVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
}
|
||||||
@ -13,10 +13,7 @@ struct readeckApp: App {
|
|||||||
@State private var appViewModel = AppViewModel()
|
@State private var appViewModel = AppViewModel()
|
||||||
@StateObject private var appSettings = AppSettings()
|
@StateObject private var appSettings = AppSettings()
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
@State private var showDebugMenu = false
|
@State private var showDebugMenu = false
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@ -31,19 +28,23 @@ struct readeckApp: App {
|
|||||||
.environmentObject(appSettings)
|
.environmentObject(appSettings)
|
||||||
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
.environment(\.managedObjectContext, CoreDataManager.shared.context)
|
||||||
.preferredColorScheme(appSettings.theme.colorScheme)
|
.preferredColorScheme(appSettings.theme.colorScheme)
|
||||||
#if DEBUG
|
|
||||||
.onShake {
|
.onShake {
|
||||||
showDebugMenu = true
|
// Only show debug menu in non-production builds (DEBUG + TestFlight)
|
||||||
|
if !Bundle.main.isProduction {
|
||||||
|
showDebugMenu = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showDebugMenu) {
|
.sheet(isPresented: $showDebugMenu) {
|
||||||
DebugMenuView()
|
DebugMenuView()
|
||||||
.environmentObject(appSettings)
|
.environmentObject(appSettings)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
// Start NetFox in non-production builds
|
||||||
NFX.sharedInstance().start()
|
if !Bundle.main.isProduction {
|
||||||
#endif
|
// Disable NetFox shake gesture since we use it for our debug menu
|
||||||
|
NFX.sharedInstance().setGesture(.custom)
|
||||||
|
NFX.sharedInstance().start()
|
||||||
|
}
|
||||||
Task {
|
Task {
|
||||||
await loadAppSettings()
|
await loadAppSettings()
|
||||||
}
|
}
|
||||||
|
|||||||
176
readeckTests/Domain/ServerInfoTests.swift
Normal file
176
readeckTests/Domain/ServerInfoTests.swift
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// ServerInfoTests.swift
|
||||||
|
// readeckTests
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import readeck
|
||||||
|
|
||||||
|
final class ServerInfoTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - OAuth Feature Detection
|
||||||
|
|
||||||
|
func testSupportsOAuth_WithOAuthFeature_ReturnsTrue() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: ["oauth", "email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(serverInfo.supportsOAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsOAuth_WithoutOAuthFeature_ReturnsFalse() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: ["email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsOAuth_WithNilFeatures_ReturnsFalse() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth, "Should return false for old servers without features array")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsOAuth_WithEmptyFeatures_ReturnsFalse() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: []
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Email Feature Detection
|
||||||
|
|
||||||
|
func testSupportsEmail_WithEmailFeature_ReturnsTrue() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: ["oauth", "email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsEmail_WithoutEmailFeature_ReturnsFalse() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: ["oauth"]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupportsEmail_WithNilFeatures_ReturnsFalse() {
|
||||||
|
let serverInfo = ServerInfo(
|
||||||
|
version: "1.0.0",
|
||||||
|
isReachable: true,
|
||||||
|
features: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DTO Conversion
|
||||||
|
|
||||||
|
func testInit_FromDto_WithFeatures() {
|
||||||
|
let dto = ServerInfoDto(
|
||||||
|
version: ServerInfoDto.VersionInfo(canonical: "1.2.3", release: "1.2.3", build: "abc123"),
|
||||||
|
features: ["oauth", "email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
|
||||||
|
XCTAssertEqual(serverInfo.version, "1.2.3")
|
||||||
|
XCTAssertTrue(serverInfo.isReachable)
|
||||||
|
XCTAssertEqual(serverInfo.features, ["oauth", "email"])
|
||||||
|
XCTAssertTrue(serverInfo.supportsOAuth)
|
||||||
|
XCTAssertTrue(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInit_FromDto_WithoutFeatures() {
|
||||||
|
let dto = ServerInfoDto(
|
||||||
|
version: ServerInfoDto.VersionInfo(canonical: "0.9.0", release: "0.9.0", build: ""),
|
||||||
|
features: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
|
||||||
|
XCTAssertEqual(serverInfo.version, "0.9.0")
|
||||||
|
XCTAssertTrue(serverInfo.isReachable)
|
||||||
|
XCTAssertNil(serverInfo.features)
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth, "Old server without features should not support OAuth")
|
||||||
|
XCTAssertFalse(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnreachable_HasNoFeatures() {
|
||||||
|
let serverInfo = ServerInfo.unreachable
|
||||||
|
|
||||||
|
XCTAssertEqual(serverInfo.version, "")
|
||||||
|
XCTAssertFalse(serverInfo.isReachable)
|
||||||
|
XCTAssertNil(serverInfo.features)
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth)
|
||||||
|
XCTAssertFalse(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backward Compatibility
|
||||||
|
|
||||||
|
func testBackwardCompatibility_OldServerWithoutFeaturesArray() {
|
||||||
|
// Simulates an old Readeck server that doesn't include features in /api/info response
|
||||||
|
let dto = ServerInfoDto(
|
||||||
|
version: ServerInfoDto.VersionInfo(canonical: "0.5.0", release: "0.5.0", build: ""),
|
||||||
|
features: nil // Old server doesn't send features
|
||||||
|
)
|
||||||
|
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
|
||||||
|
// Should work without crashing
|
||||||
|
XCTAssertNotNil(serverInfo)
|
||||||
|
XCTAssertEqual(serverInfo.version, "0.5.0")
|
||||||
|
|
||||||
|
// OAuth detection should gracefully return false
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth, "Old servers should gracefully report no OAuth support")
|
||||||
|
XCTAssertFalse(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackwardCompatibility_NewServerWithFeaturesArray() {
|
||||||
|
// Simulates a new Readeck server with OAuth support
|
||||||
|
let dto = ServerInfoDto(
|
||||||
|
version: ServerInfoDto.VersionInfo(canonical: "1.0.0", release: "1.0.0", build: "xyz"),
|
||||||
|
features: ["oauth", "email"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
|
||||||
|
XCTAssertNotNil(serverInfo)
|
||||||
|
XCTAssertTrue(serverInfo.supportsOAuth, "New servers with oauth feature should report OAuth support")
|
||||||
|
XCTAssertTrue(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBackwardCompatibility_NewServerWithoutOAuthFeature() {
|
||||||
|
// Simulates a new server that has features array but OAuth is disabled
|
||||||
|
let dto = ServerInfoDto(
|
||||||
|
version: ServerInfoDto.VersionInfo(canonical: "1.0.0", release: "1.0.0", build: "xyz"),
|
||||||
|
features: ["email"] // Has features array but no oauth
|
||||||
|
)
|
||||||
|
|
||||||
|
let serverInfo = ServerInfo(from: dto)
|
||||||
|
|
||||||
|
XCTAssertNotNil(serverInfo)
|
||||||
|
XCTAssertFalse(serverInfo.supportsOAuth, "Server with features but no oauth should report no OAuth support")
|
||||||
|
XCTAssertTrue(serverInfo.supportsEmail)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
readeckTests/OAuth/PKCEGeneratorTests.swift
Normal file
130
readeckTests/OAuth/PKCEGeneratorTests.swift
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
//
|
||||||
|
// PKCEGeneratorTests.swift
|
||||||
|
// readeckTests
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 15.12.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import readeck
|
||||||
|
|
||||||
|
final class PKCEGeneratorTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - Code Verifier Tests
|
||||||
|
|
||||||
|
func testGenerateCodeVerifier_ReturnsCorrectLength() {
|
||||||
|
let verifier = PKCEGenerator.generateCodeVerifier()
|
||||||
|
|
||||||
|
XCTAssertEqual(verifier.count, 64, "Code verifier should be 64 characters long")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeVerifier_ContainsOnlyAllowedCharacters() {
|
||||||
|
let verifier = PKCEGenerator.generateCodeVerifier()
|
||||||
|
let allowedCharacterSet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
|
||||||
|
|
||||||
|
for character in verifier {
|
||||||
|
XCTAssertTrue(
|
||||||
|
allowedCharacterSet.contains(character.unicodeScalars.first!),
|
||||||
|
"Verifier should only contain alphanumeric characters"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeVerifier_GeneratesUniqueValues() {
|
||||||
|
let verifier1 = PKCEGenerator.generateCodeVerifier()
|
||||||
|
let verifier2 = PKCEGenerator.generateCodeVerifier()
|
||||||
|
|
||||||
|
XCTAssertNotEqual(verifier1, verifier2, "Each verifier should be unique")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Code Challenge Tests
|
||||||
|
|
||||||
|
func testGenerateCodeChallenge_ReturnsNonEmptyString() {
|
||||||
|
let verifier = "test_verifier_1234567890abcdefghijklmnopqrstuvwxyz1234567890123"
|
||||||
|
let challenge = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
XCTAssertFalse(challenge.isEmpty, "Code challenge should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeChallenge_IsBase64UrlEncoded() {
|
||||||
|
let verifier = PKCEGenerator.generateCodeVerifier()
|
||||||
|
let challenge = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
// Base64url should not contain +, /, or =
|
||||||
|
XCTAssertFalse(challenge.contains("+"), "Challenge should not contain '+'")
|
||||||
|
XCTAssertFalse(challenge.contains("/"), "Challenge should not contain '/'")
|
||||||
|
XCTAssertFalse(challenge.contains("="), "Challenge should not contain '=' padding")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeChallenge_ContainsOnlyBase64UrlCharacters() {
|
||||||
|
let verifier = PKCEGenerator.generateCodeVerifier()
|
||||||
|
let challenge = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
let allowedCharacterSet = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
|
||||||
|
|
||||||
|
for character in challenge {
|
||||||
|
XCTAssertTrue(
|
||||||
|
allowedCharacterSet.contains(character.unicodeScalars.first!),
|
||||||
|
"Challenge should only contain base64url characters (A-Z, a-z, 0-9, -, _)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeChallenge_IsDeterministic() {
|
||||||
|
let verifier = "consistent_verifier_for_testing_purposes_1234567890abcdefghij"
|
||||||
|
let challenge1 = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
let challenge2 = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
XCTAssertEqual(challenge1, challenge2, "Same verifier should always produce same challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerateCodeChallenge_DifferentVerifiersProduceDifferentChallenges() {
|
||||||
|
let verifier1 = PKCEGenerator.generateCodeVerifier()
|
||||||
|
let verifier2 = PKCEGenerator.generateCodeVerifier()
|
||||||
|
|
||||||
|
let challenge1 = PKCEGenerator.generateCodeChallenge(from: verifier1)
|
||||||
|
let challenge2 = PKCEGenerator.generateCodeChallenge(from: verifier2)
|
||||||
|
|
||||||
|
XCTAssertNotEqual(challenge1, challenge2, "Different verifiers should produce different challenges")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Combined Generation Tests
|
||||||
|
|
||||||
|
func testGenerate_ReturnsVerifierAndChallenge() {
|
||||||
|
let (verifier, challenge) = PKCEGenerator.generate()
|
||||||
|
|
||||||
|
XCTAssertEqual(verifier.count, 64, "Verifier should be 64 characters")
|
||||||
|
XCTAssertFalse(challenge.isEmpty, "Challenge should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerate_ChallengeMatchesVerifier() {
|
||||||
|
let (verifier, challenge) = PKCEGenerator.generate()
|
||||||
|
let expectedChallenge = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
XCTAssertEqual(challenge, expectedChallenge, "Challenge should match the one generated from verifier")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGenerate_ProducesUniqueValues() {
|
||||||
|
let (verifier1, challenge1) = PKCEGenerator.generate()
|
||||||
|
let (verifier2, challenge2) = PKCEGenerator.generate()
|
||||||
|
|
||||||
|
XCTAssertNotEqual(verifier1, verifier2, "Each verifier should be unique")
|
||||||
|
XCTAssertNotEqual(challenge1, challenge2, "Each challenge should be unique")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - RFC 7636 Compliance Tests
|
||||||
|
|
||||||
|
func testPKCE_RFC7636_Example() {
|
||||||
|
// Test with a known example to verify SHA-256 + base64url encoding
|
||||||
|
// This ensures our implementation matches the RFC spec
|
||||||
|
|
||||||
|
// Using a simple known verifier for testing
|
||||||
|
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
let challenge = PKCEGenerator.generateCodeChallenge(from: verifier)
|
||||||
|
|
||||||
|
// Expected challenge (calculated externally using RFC 7636 algorithm)
|
||||||
|
let expectedChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
|
XCTAssertEqual(challenge, expectedChallenge, "Challenge should match RFC 7636 example")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user