From 525e6557c8f929acc6c6a5bfa1bb813b859285ec Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Fri, 22 May 2026 14:14:54 -0500 Subject: [PATCH] Replace manual backend config with auto JWT auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device auto-registers on first launch using hardcoded backend URL and anon key (MarksAuth.swift). 90-day JWT stored in UserDefaults, refreshed transparently. No settings fields needed — ClaudeService is always active and non-optional throughout the app. Co-Authored-By: Claude Sonnet 4.6 --- .env.sample | 2 ++ .metadata | 30 ++++++++++++++++ .runner | 14 ++++++++ Marks/MarksApp.swift | 3 +- Marks/Services/ClaudeService.swift | 31 ++++------------- Marks/Services/MarksAuth.swift | 52 ++++++++++++++++++++++++++++ Marks/Views/BookmarksView.swift | 40 +++++++++------------ Marks/Views/BookmarksViewModel.swift | 23 ++++-------- Marks/Views/BrowserView.swift | 19 +++------- Marks/Views/SearchView.swift | 15 ++++---- Marks/Views/SettingsView.swift | 51 +++------------------------ 11 files changed, 143 insertions(+), 137 deletions(-) create mode 100644 .env.sample create mode 100644 .metadata create mode 100644 .runner create mode 100644 Marks/Services/MarksAuth.swift diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..db332bb --- /dev/null +++ b/.env.sample @@ -0,0 +1,2 @@ +SENTRY_DSN= +ENABLE_SENTRY=false \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..fc44c8e --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + - platform: android + create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.runner b/.runner new file mode 100644 index 0000000..5e2738c --- /dev/null +++ b/.runner @@ -0,0 +1,14 @@ +{ + "WARNING": "This file is automatically generated by act-runner. Do not edit it manually unless you know what you are doing. Removing this file will cause act runner to re-register as a new runner.", + "id": 3, + "uuid": "68105c19-5047-4a36-9342-2877162f169e", + "name": "marks-runner", + "token": "1e23b24d76fc51bd9b442349a780c38fceced821", + "address": "https://git.magicive.com", + "labels": [ + "macos:host", + "arm64:host", + "ios:host", + "xcode:host" + ] +} diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index ffe18e5..9df6e32 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -32,8 +32,7 @@ struct MainContainer: View { self.config = config self.onDisconnect = onDisconnect let api = LinkdingAPI(config: config) - let claude = ClaudeService.load() - _viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host)) + _viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host)) } var body: some View { diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift index b972539..5c5ca45 100644 --- a/Marks/Services/ClaudeService.swift +++ b/Marks/Services/ClaudeService.swift @@ -11,23 +11,6 @@ struct PodcastStatus: Decodable { } struct ClaudeService: Sendable { - let backendUrl: String - let apiKey: String - - static let urlKey = "marksBackendUrl" - static let apiKeyKey = "marksApiKey" - - static func load() -> ClaudeService? { - guard let url = UserDefaults.standard.string(forKey: urlKey), !url.isEmpty, - let key = UserDefaults.standard.string(forKey: apiKeyKey), !key.isEmpty - else { return nil } - return ClaudeService(backendUrl: url, apiKey: key) - } - - static func save(backendUrl: String, apiKey: String) { - UserDefaults.standard.set(backendUrl, forKey: urlKey) - UserDefaults.standard.set(apiKey, forKey: apiKeyKey) - } // MARK: - Bookmark AI @@ -94,15 +77,12 @@ struct ClaudeService: Sendable { // MARK: - HTTP primitives - private var base: String { - backendUrl.hasSuffix("/") ? String(backendUrl.dropLast()) : backendUrl - } - private func post(_ path: String, body: [String: Any]) async throws -> Data { - guard let url = URL(string: base + path) else { throw APIError.invalidUrl } + let token = try await MarksAuth.validToken() + guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) request.httpMethod = "POST" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONSerialization.data(withJSONObject: body) print("[AI] POST \(path)") @@ -117,9 +97,10 @@ struct ClaudeService: Sendable { } private func get(_ path: String) async throws -> Data { - guard let url = URL(string: base + path) else { throw APIError.invalidUrl } + let token = try await MarksAuth.validToken() + guard let url = URL(string: MarksAuth.baseURL + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") print("[AI] GET \(path)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 diff --git a/Marks/Services/MarksAuth.swift b/Marks/Services/MarksAuth.swift new file mode 100644 index 0000000..e4c4191 --- /dev/null +++ b/Marks/Services/MarksAuth.swift @@ -0,0 +1,52 @@ +import Foundation +import UIKit + +// Hardcoded backend config — no user-facing settings needed +private let backendBaseURL = "https://magicive-api-production.up.railway.app" +private let marksAnonKey = "marks-anon-2025" + +private let tokenKey = "marksJWT" +private let tokenExpiryKey = "marksJWTExpiry" + +enum MarksAuth { + static var baseURL: String { backendBaseURL } + + /// Returns a valid JWT, registering if missing or within 1 hour of expiry. + static func validToken() async throws -> String { + if let token = cached(), !token.isEmpty { return token } + return try await register() + } + + private static func cached() -> String? { + guard + let token = UserDefaults.standard.string(forKey: tokenKey), + let expiry = UserDefaults.standard.object(forKey: tokenExpiryKey) as? Date, + expiry > Date().addingTimeInterval(3600) + else { return nil } + return token + } + + private static func register() async throws -> String { + guard let url = URL(string: "\(backendBaseURL)/api/auth/register") else { + throw URLError(.badURL) + } + let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue(marksAnonKey, forHTTPHeaderField: "X-API-Key") + req.httpBody = try JSONSerialization.data(withJSONObject: ["device_id": deviceId]) + let (data, response) = try await URLSession.shared.data(for: req) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(status) else { + throw APIError.badStatus(status) + } + struct Resp: Decodable { let access_token: String; let expires_in: Int } + let resp = try JSONDecoder().decode(Resp.self, from: data) + UserDefaults.standard.set(resp.access_token, forKey: tokenKey) + let expiry = Date().addingTimeInterval(TimeInterval(resp.expires_in)) + UserDefaults.standard.set(expiry, forKey: tokenExpiryKey) + print("[Auth] Registered device \(deviceId.prefix(8))…") + return resp.access_token + } +} diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 5f8e13f..5634a68 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -61,12 +61,10 @@ struct BookmarksView: View { } label: { Label("Open in Safari", systemImage: "safari") } - if viewModel.claude != nil { - Button { - podcastBookmark = bookmark - } label: { - Label("Convert to Podcast", systemImage: "headphones") - } + Button { + podcastBookmark = bookmark + } label: { + Label("Convert to Podcast", systemImage: "headphones") } Divider() Button { @@ -92,22 +90,20 @@ struct BookmarksView: View { .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .topBarLeading) { - if viewModel.claude != nil { - Menu { - Button { - Task { await viewModel.generateSmartCollections() } - showCollections = true - } label: { - Label("Smart Collections", systemImage: "sparkles") - } - Button { - Task { await viewModel.enrichAll() } - } label: { - Label("Enrich All with AI", systemImage: "wand.and.stars") - } + Menu { + Button { + Task { await viewModel.generateSmartCollections() } + showCollections = true } label: { - Image(systemName: "sparkles") + Label("Smart Collections", systemImage: "sparkles") } + Button { + Task { await viewModel.enrichAll() } + } label: { + Label("Enrich All with AI", systemImage: "wand.and.stars") + } + } label: { + Image(systemName: "sparkles") } } ToolbarItem(placement: .topBarTrailing) { @@ -163,9 +159,7 @@ struct BookmarksView: View { if new == nil { readingProgress = ReadingProgress.all() } } .sheet(item: $podcastBookmark) { bookmark in - if let claude = viewModel.claude { - PodcastPlayerView(articleUrl: bookmark.url, claude: claude) - } + PodcastPlayerView(articleUrl: bookmark.url, claude: viewModel.claude) } .refreshable { await viewModel.load() diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 5caa8f3..ff099c7 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -16,23 +16,18 @@ final class BookmarksViewModel { var unreadFilter = false private let api: LinkdingAPI - private(set) var claude: ClaudeService? + let claude = ClaudeService() private var enrichTask: Task? private let cacheFileUrl: URL - init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") { + init(api: LinkdingAPI, cacheKey: String = "") { self.api = api - self.claude = claude let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_") let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)" self.cacheFileUrl = dir.appendingPathComponent("\(name).json") } - func updateClaude(_ service: ClaudeService?) { - claude = service - } - func load(useCache: Bool = true) async { if useCache { loadFromCache() } isLoading = true @@ -83,7 +78,7 @@ final class BookmarksViewModel { } func semanticSearch() async { - guard let claude, !searchQuery.isEmpty else { return } + guard !searchQuery.isEmpty else { return } isLoading = true do { let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter) @@ -102,8 +97,7 @@ final class BookmarksViewModel { let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false) let bookmark = try await api.createBookmark(create) bookmarks.insert(bookmark, at: 0) - print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")") - guard let claude else { return } + print("[AI] addBookmark: saved id=\(bookmark.id)") Task { print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)") do { @@ -159,7 +153,7 @@ final class BookmarksViewModel { } func generateSmartCollections() async { - guard let claude, !bookmarks.isEmpty else { return } + guard !bookmarks.isEmpty else { return } isGeneratingCollections = true do { let allResponse = try await api.fetchBookmarks(limit: 200) @@ -171,7 +165,6 @@ final class BookmarksViewModel { } func enrichAll() async { - guard let claude else { return } let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil } guard !toEnrich.isEmpty else { return } enrichmentProgress = 0 @@ -191,10 +184,6 @@ final class BookmarksViewModel { // Silently enrich new bookmarks that lack summaries (background, non-blocking) private func startEnrichment() { - guard claude != nil else { - print("[AI] startEnrichment: skipped (no claude)") - return - } enrichTask?.cancel() enrichTask = Task { [weak self] in guard let self else { return } @@ -202,7 +191,7 @@ final class BookmarksViewModel { print("[AI] startEnrichment: \(indices.count) bookmarks to enrich") for i in indices { guard !Task.isCancelled else { return } - guard let claude = await MainActor.run(body: { self.claude }) else { return } + let claude = await MainActor.run(body: { self.claude }) do { let bm = await MainActor.run { bookmarks[i] } print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)") diff --git a/Marks/Views/BrowserView.swift b/Marks/Views/BrowserView.swift index 7638704..ab7ae2a 100644 --- a/Marks/Views/BrowserView.swift +++ b/Marks/Views/BrowserView.swift @@ -185,16 +185,15 @@ private struct WebViewRepresentable: UIViewRepresentable { struct BrowserView: View { let initialURL: URL let initialTitle: String - let claude: ClaudeService? + let claude: ClaudeService @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var state: BrowserState @State private var readerMode = false @State private var showPodcast = false - @State private var showAISetupAlert = false - init(url: URL, title: String, claude: ClaudeService? = nil) { + init(url: URL, title: String, claude: ClaudeService) { self.initialURL = url self.initialTitle = title self.claude = claude @@ -262,10 +261,7 @@ struct BrowserView: View { Spacer() - Button { - if claude != nil { showPodcast = true } - else { showAISetupAlert = true } - } label: { + Button { showPodcast = true } label: { Image(systemName: "headphones") } .disabled(state.isLoading) @@ -284,14 +280,7 @@ struct BrowserView: View { if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } } .sheet(isPresented: $showPodcast) { - if let claude { - PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude) - } - } - .alert("AI Not Configured", isPresented: $showAISetupAlert) { - Button("OK") {} - } message: { - Text("Add your Backend URL and API Key in Settings to use podcast generation.") + PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: claude) } } } diff --git a/Marks/Views/SearchView.swift b/Marks/Views/SearchView.swift index 4ab1b8d..a495f27 100644 --- a/Marks/Views/SearchView.swift +++ b/Marks/Views/SearchView.swift @@ -31,14 +31,12 @@ struct SearchView: View { .listStyle(.plain) .navigationTitle("Search") .toolbar { - if viewModel.claude != nil { - ToolbarItem(placement: .topBarTrailing) { - Toggle(isOn: $useSemanticSearch) { - Label("Semantic", systemImage: "sparkles") - } - .toggleStyle(.button) - .onChange(of: useSemanticSearch) { _, _ in scheduleSearch() } + ToolbarItem(placement: .topBarTrailing) { + Toggle(isOn: $useSemanticSearch) { + Label("Semantic", systemImage: "sparkles") } + .toggleStyle(.button) + .onChange(of: useSemanticSearch) { _, _ in scheduleSearch() } } } .overlay { @@ -72,7 +70,8 @@ struct SearchView: View { private func scheduleSearch() { semanticResults = nil searchTask?.cancel() - guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return } + guard !searchText.isEmpty, useSemanticSearch else { return } + let claude = viewModel.claude searchTask = Task { try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 609e992..236cce2 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -4,50 +4,15 @@ struct SettingsView: View { @Bindable var viewModel: BookmarksViewModel let onDisconnect: () -> Void - @State private var backendUrl = UserDefaults.standard.string(forKey: ClaudeService.urlKey) ?? "" - @State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.apiKeyKey) ?? "" @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { - Section { - VStack(alignment: .leading, spacing: 6) { - Text("Backend URL") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - TextField("https://api.yourserver.com", text: $backendUrl) - .textContentType(.URL) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.URL) - .onChange(of: backendUrl) { _, _ in reloadClaude() } - } - .padding(.vertical, 4) - - VStack(alignment: .leading, spacing: 6) { - Text("API Key") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - SecureField("Bearer token", text: $claudeKey) - .textContentType(.password) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .onChange(of: claudeKey) { _, _ in reloadClaude() } - } - .padding(.vertical, 4) - - if viewModel.claude != nil { - Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } else { - Text("Enter your backend URL and API key to enable AI features.") - .font(.system(size: 13)) - .foregroundStyle(.secondary) - } - } header: { - Text("AI Features") + Section("AI Features") { + Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles") + .font(.system(size: 13)) + .foregroundStyle(.secondary) } Section("Server") { @@ -68,12 +33,4 @@ struct SettingsView: View { } } } - - private func reloadClaude() { - ClaudeService.save(backendUrl: backendUrl, apiKey: claudeKey) - let service = backendUrl.isEmpty || claudeKey.isEmpty - ? nil - : ClaudeService(backendUrl: backendUrl, apiKey: claudeKey) - viewModel.updateClaude(service) - } }