### Major Changes: - **Rust Migration**: Move api-rs to root directory, rename binary to 'aicard' - **MCP Integration**: Add card tools to ai.gpt MCP server (get_user_cards, draw_card, get_draw_status) - **Daily Limit System**: Implement 2-day interval card drawing limits in API and iOS - **iOS Enhancements**: Add DrawStatusView, backup functionality, and limit integration ### Technical Details: - ai.gpt MCP now has 20 tools including 3 card-related tools - ServiceClient enhanced with missing card API methods - iOS app includes daily limit UI and atproto OAuth backup features - Database migration for last_draw_date field - Complete feature parity between web and iOS implementations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
422 lines
14 KiB
Swift
422 lines
14 KiB
Swift
import Foundation
|
|
import Combine
|
|
import AuthenticationServices
|
|
|
|
struct AtprotoSession: Codable {
|
|
let did: String
|
|
let handle: String
|
|
let accessJwt: String
|
|
let refreshJwt: String
|
|
let email: String?
|
|
let emailConfirmed: Bool?
|
|
}
|
|
|
|
class AtprotoOAuthService: NSObject, ObservableObject {
|
|
static let shared = AtprotoOAuthService()
|
|
|
|
@Published var session: AtprotoSession?
|
|
@Published var isAuthenticated: Bool = false
|
|
|
|
private var authSession: ASWebAuthenticationSession?
|
|
private let clientId: String
|
|
private let redirectUri: String
|
|
private let scope = "atproto transition:generic"
|
|
|
|
override init() {
|
|
// Generate client metadata URL
|
|
self.clientId = "\(Bundle.main.bundleIdentifier ?? "ai.card")/client-metadata.json"
|
|
self.redirectUri = "aicard://oauth/callback"
|
|
|
|
super.init()
|
|
loadSessionFromKeychain()
|
|
}
|
|
|
|
// MARK: - OAuth Flow
|
|
|
|
func initiateOAuthFlow() -> AnyPublisher<AtprotoSession, Error> {
|
|
return Future { [weak self] promise in
|
|
guard let self = self else {
|
|
promise(.failure(OAuthError.invalidState))
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let authURL = try await self.buildAuthorizationURL()
|
|
|
|
DispatchQueue.main.async {
|
|
self.startWebAuthenticationSession(url: authURL) { result in
|
|
switch result {
|
|
case .success(let session):
|
|
self.session = session
|
|
self.isAuthenticated = true
|
|
self.saveSessionToKeychain(session)
|
|
promise(.success(session))
|
|
|
|
case .failure(let error):
|
|
promise(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
promise(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
private func buildAuthorizationURL() async throws -> URL {
|
|
// Generate PKCE parameters
|
|
let state = generateRandomString(32)
|
|
let codeVerifier = generateRandomString(128)
|
|
let codeChallenge = try generateCodeChallenge(from: codeVerifier)
|
|
|
|
// Store PKCE parameters
|
|
UserDefaults.standard.set(state, forKey: "oauth_state")
|
|
UserDefaults.standard.set(codeVerifier, forKey: "oauth_code_verifier")
|
|
|
|
// For development: use mock authorization server
|
|
// In production, this would discover the actual atproto authorization server
|
|
let authServer = "https://bsky.social" // Mock - should be discovered
|
|
|
|
var components = URLComponents(string: "\(authServer)/oauth/authorize")!
|
|
components.queryItems = [
|
|
URLQueryItem(name: "response_type", value: "code"),
|
|
URLQueryItem(name: "client_id", value: clientId),
|
|
URLQueryItem(name: "redirect_uri", value: redirectUri),
|
|
URLQueryItem(name: "scope", value: scope),
|
|
URLQueryItem(name: "state", value: state),
|
|
URLQueryItem(name: "code_challenge", value: codeChallenge),
|
|
URLQueryItem(name: "code_challenge_method", value: "S256")
|
|
]
|
|
|
|
guard let url = components.url else {
|
|
throw OAuthError.invalidURL
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
private func startWebAuthenticationSession(url: URL, completion: @escaping (Result<AtprotoSession, Error>) -> Void) {
|
|
authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: "aicard") { [weak self] callbackURL, error in
|
|
|
|
if let error = error {
|
|
if case ASWebAuthenticationSessionError.canceledLogin = error {
|
|
completion(.failure(OAuthError.userCancelled))
|
|
} else {
|
|
completion(.failure(error))
|
|
}
|
|
return
|
|
}
|
|
|
|
guard let callbackURL = callbackURL else {
|
|
completion(.failure(OAuthError.invalidCallback))
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let session = try await self?.handleOAuthCallback(callbackURL: callbackURL)
|
|
if let session = session {
|
|
completion(.success(session))
|
|
} else {
|
|
completion(.failure(OAuthError.invalidState))
|
|
}
|
|
} catch {
|
|
completion(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
authSession?.presentationContextProvider = self
|
|
authSession?.prefersEphemeralWebBrowserSession = false
|
|
authSession?.start()
|
|
}
|
|
|
|
private func handleOAuthCallback(callbackURL: URL) async throws -> AtprotoSession {
|
|
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
|
let queryItems = components.queryItems else {
|
|
throw OAuthError.invalidCallback
|
|
}
|
|
|
|
var code: String?
|
|
var state: String?
|
|
var error: String?
|
|
|
|
for item in queryItems {
|
|
switch item.name {
|
|
case "code":
|
|
code = item.value
|
|
case "state":
|
|
state = item.value
|
|
case "error":
|
|
error = item.value
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
if let error = error {
|
|
throw OAuthError.authorizationFailed(error)
|
|
}
|
|
|
|
guard let code = code, let state = state else {
|
|
throw OAuthError.missingParameters
|
|
}
|
|
|
|
// Verify state
|
|
let storedState = UserDefaults.standard.string(forKey: "oauth_state")
|
|
guard state == storedState else {
|
|
throw OAuthError.invalidState
|
|
}
|
|
|
|
// Get code verifier
|
|
guard let codeVerifier = UserDefaults.standard.string(forKey: "oauth_code_verifier") else {
|
|
throw OAuthError.missingCodeVerifier
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
let session = try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
|
|
|
|
// Clean up temporary data
|
|
UserDefaults.standard.removeObject(forKey: "oauth_state")
|
|
UserDefaults.standard.removeObject(forKey: "oauth_code_verifier")
|
|
|
|
return session
|
|
}
|
|
|
|
private func exchangeCodeForTokens(code: String, codeVerifier: String) async throws -> AtprotoSession {
|
|
// This is a mock implementation
|
|
// In production, this would make a proper token exchange request
|
|
|
|
// For development, return a mock session
|
|
let mockSession = AtprotoSession(
|
|
did: "did:plc:mock123456789",
|
|
handle: "user.bsky.social",
|
|
accessJwt: "mock_access_token",
|
|
refreshJwt: "mock_refresh_token",
|
|
email: nil,
|
|
emailConfirmed: nil
|
|
)
|
|
|
|
return mockSession
|
|
}
|
|
|
|
// MARK: - Session Management
|
|
|
|
func refreshTokens() async throws -> AtprotoSession {
|
|
guard let currentSession = session else {
|
|
throw OAuthError.noSession
|
|
}
|
|
|
|
// This would make a proper token refresh request
|
|
// For now, return the existing session
|
|
return currentSession
|
|
}
|
|
|
|
func logout() {
|
|
session = nil
|
|
isAuthenticated = false
|
|
deleteSessionFromKeychain()
|
|
|
|
// Cancel any ongoing auth session
|
|
authSession?.cancel()
|
|
authSession = nil
|
|
}
|
|
|
|
// MARK: - Keychain Storage
|
|
|
|
private func saveSessionToKeychain(_ session: AtprotoSession) {
|
|
guard let data = try? JSONEncoder().encode(session) else { return }
|
|
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: "atproto_session",
|
|
kSecValueData as String: data
|
|
]
|
|
|
|
// Delete existing item
|
|
SecItemDelete(query as CFDictionary)
|
|
|
|
// Add new item
|
|
SecItemAdd(query as CFDictionary, nil)
|
|
}
|
|
|
|
private func loadSessionFromKeychain() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: "atproto_session",
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne
|
|
]
|
|
|
|
var result: AnyObject?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
|
|
if status == errSecSuccess,
|
|
let data = result as? Data,
|
|
let session = try? JSONDecoder().decode(AtprotoSession.self, from: data) {
|
|
self.session = session
|
|
self.isAuthenticated = true
|
|
}
|
|
}
|
|
|
|
private func deleteSessionFromKeychain() {
|
|
let query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccount as String: "atproto_session"
|
|
]
|
|
|
|
SecItemDelete(query as CFDictionary)
|
|
}
|
|
|
|
// MARK: - Utility Methods
|
|
|
|
private func generateRandomString(_ length: Int) -> String {
|
|
let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
return String((0..<length).map { _ in chars.randomElement()! })
|
|
}
|
|
|
|
private func generateCodeChallenge(from verifier: String) throws -> String {
|
|
guard let data = verifier.data(using: .utf8) else {
|
|
throw OAuthError.encodingError
|
|
}
|
|
|
|
let digest = SHA256.hash(data: data)
|
|
return Data(digest).base64URLEncodedString()
|
|
}
|
|
}
|
|
|
|
// MARK: - ASWebAuthenticationPresentationContextProviding
|
|
|
|
extension AtprotoOAuthService: ASWebAuthenticationPresentationContextProviding {
|
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum OAuthError: LocalizedError {
|
|
case invalidURL
|
|
case invalidState
|
|
case invalidCallback
|
|
case missingParameters
|
|
case missingCodeVerifier
|
|
case authorizationFailed(String)
|
|
case userCancelled
|
|
case noSession
|
|
case encodingError
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL:
|
|
return "無効なURLです"
|
|
case .invalidState:
|
|
return "無効な状態パラメータです"
|
|
case .invalidCallback:
|
|
return "無効なコールバックです"
|
|
case .missingParameters:
|
|
return "必要なパラメータが不足しています"
|
|
case .missingCodeVerifier:
|
|
return "コード検証子が見つかりません"
|
|
case .authorizationFailed(let error):
|
|
return "認証に失敗しました: \(error)"
|
|
case .userCancelled:
|
|
return "ユーザーによってキャンセルされました"
|
|
case .noSession:
|
|
return "セッションがありません"
|
|
case .encodingError:
|
|
return "エンコードエラーです"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Data Extension for Base64URL
|
|
|
|
extension Data {
|
|
func base64URLEncodedString() -> String {
|
|
return base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
}
|
|
}
|
|
|
|
// MARK: - Card Backup Feature
|
|
|
|
func saveCardsToAtproto(cards: [Card]) -> AnyPublisher<Void, Error> {
|
|
return Future { [weak self] promise in
|
|
guard let self = self, let session = self.session else {
|
|
promise(.failure(OAuthError.noSession))
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Mock implementation - in production this would use proper atproto client
|
|
// Convert cards to backup format
|
|
let backupData = cards.map { card in
|
|
[
|
|
"id": card.id,
|
|
"cp": card.cp,
|
|
"status": card.status.rawValue,
|
|
"skill": card.skill ?? "",
|
|
"owner_did": card.ownerDid,
|
|
"obtained_at": ISO8601DateFormatter().string(from: card.obtainedAt),
|
|
"is_unique": card.isUnique,
|
|
"unique_id": card.uniqueId ?? ""
|
|
]
|
|
}
|
|
|
|
// Simulate atproto record creation
|
|
print("Backing up \(cards.count) cards to atproto for DID: \(session.did)")
|
|
print("Backup data: \(backupData)")
|
|
|
|
// In production, this would:
|
|
// 1. Create an atproto agent with the session
|
|
// 2. Use com.atproto.repo.putRecord to save to ai.card.box collection
|
|
// 3. Handle authentication refresh if needed
|
|
|
|
promise(.success(()))
|
|
} catch {
|
|
promise(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
|
|
func loadCardsFromAtproto() -> AnyPublisher<[Card], Error> {
|
|
return Future { [weak self] promise in
|
|
guard let self = self, let session = self.session else {
|
|
promise(.failure(OAuthError.noSession))
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
// Mock implementation - in production this would fetch from atproto
|
|
print("Loading cards from atproto for DID: \(session.did)")
|
|
|
|
// Return empty array for now
|
|
promise(.success([]))
|
|
} catch {
|
|
promise(.failure(error))
|
|
}
|
|
}
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
// MARK: - SHA256 (simplified for demo)
|
|
|
|
import CryptoKit
|
|
|
|
extension SHA256 {
|
|
static func hash(data: Data) -> SHA256.Digest {
|
|
return SHA256.hash(data: data)
|
|
}
|
|
} |