Replace manual backend config with auto JWT auth
Some checks failed
CI / build-and-deploy (push) Failing after 6s

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 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-22 14:14:54 -05:00
parent 9f0a10d228
commit 525e6557c8
11 changed files with 143 additions and 137 deletions

2
.env.sample Normal file
View File

@@ -0,0 +1,2 @@
SENTRY_DSN=
ENABLE_SENTRY=false

30
.metadata Normal file
View File

@@ -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'

14
.runner Normal file
View File

@@ -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"
]
}

View File

@@ -32,8 +32,7 @@ struct MainContainer: View {
self.config = config self.config = config
self.onDisconnect = onDisconnect self.onDisconnect = onDisconnect
let api = LinkdingAPI(config: config) let api = LinkdingAPI(config: config)
let claude = ClaudeService.load() _viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude, cacheKey: config.host))
} }
var body: some View { var body: some View {

View File

@@ -11,23 +11,6 @@ struct PodcastStatus: Decodable {
} }
struct ClaudeService: Sendable { 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 // MARK: - Bookmark AI
@@ -94,15 +77,12 @@ struct ClaudeService: Sendable {
// MARK: - HTTP primitives // MARK: - HTTP primitives
private var base: String {
backendUrl.hasSuffix("/") ? String(backendUrl.dropLast()) : backendUrl
}
private func post(_ path: String, body: [String: Any]) async throws -> Data { 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
print("[AI] POST \(path)") print("[AI] POST \(path)")
@@ -117,9 +97,10 @@ struct ClaudeService: Sendable {
} }
private func get(_ path: String) async throws -> Data { 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) var request = URLRequest(url: url)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
print("[AI] GET \(path)") print("[AI] GET \(path)")
let (data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 let status = (response as? HTTPURLResponse)?.statusCode ?? 0

View File

@@ -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
}
}

View File

@@ -61,13 +61,11 @@ struct BookmarksView: View {
} label: { } label: {
Label("Open in Safari", systemImage: "safari") Label("Open in Safari", systemImage: "safari")
} }
if viewModel.claude != nil {
Button { Button {
podcastBookmark = bookmark podcastBookmark = bookmark
} label: { } label: {
Label("Convert to Podcast", systemImage: "headphones") Label("Convert to Podcast", systemImage: "headphones")
} }
}
Divider() Divider()
Button { Button {
Task { await viewModel.archive(bookmark) } Task { await viewModel.archive(bookmark) }
@@ -92,7 +90,6 @@ struct BookmarksView: View {
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
if viewModel.claude != nil {
Menu { Menu {
Button { Button {
Task { await viewModel.generateSmartCollections() } Task { await viewModel.generateSmartCollections() }
@@ -109,7 +106,6 @@ struct BookmarksView: View {
Image(systemName: "sparkles") Image(systemName: "sparkles")
} }
} }
}
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) { HStack(spacing: 16) {
Button { showAddBookmark = true } label: { Button { showAddBookmark = true } label: {
@@ -163,9 +159,7 @@ struct BookmarksView: View {
if new == nil { readingProgress = ReadingProgress.all() } if new == nil { readingProgress = ReadingProgress.all() }
} }
.sheet(item: $podcastBookmark) { bookmark in .sheet(item: $podcastBookmark) { bookmark in
if let claude = viewModel.claude { PodcastPlayerView(articleUrl: bookmark.url, claude: viewModel.claude)
PodcastPlayerView(articleUrl: bookmark.url, claude: claude)
}
} }
.refreshable { .refreshable {
await viewModel.load() await viewModel.load()

View File

@@ -16,23 +16,18 @@ final class BookmarksViewModel {
var unreadFilter = false var unreadFilter = false
private let api: LinkdingAPI private let api: LinkdingAPI
private(set) var claude: ClaudeService? let claude = ClaudeService()
private var enrichTask: Task<Void, Never>? private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL private let cacheFileUrl: URL
init(api: LinkdingAPI, claude: ClaudeService?, cacheKey: String = "") { init(api: LinkdingAPI, cacheKey: String = "") {
self.api = api self.api = api
self.claude = claude
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_") let safe = cacheKey.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: ":", with: "_")
let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)" let name = safe.isEmpty ? "bookmarks_cache" : "bookmarks_\(safe)"
self.cacheFileUrl = dir.appendingPathComponent("\(name).json") self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
} }
func updateClaude(_ service: ClaudeService?) {
claude = service
}
func load(useCache: Bool = true) async { func load(useCache: Bool = true) async {
if useCache { loadFromCache() } if useCache { loadFromCache() }
isLoading = true isLoading = true
@@ -83,7 +78,7 @@ final class BookmarksViewModel {
} }
func semanticSearch() async { func semanticSearch() async {
guard let claude, !searchQuery.isEmpty else { return } guard !searchQuery.isEmpty else { return }
isLoading = true isLoading = true
do { do {
let allResponse = try await api.fetchBookmarks(limit: 200, unread: unreadFilter) 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 create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create) let bookmark = try await api.createBookmark(create)
bookmarks.insert(bookmark, at: 0) bookmarks.insert(bookmark, at: 0)
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")") print("[AI] addBookmark: saved id=\(bookmark.id)")
guard let claude else { return }
Task { Task {
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)") print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
do { do {
@@ -159,7 +153,7 @@ final class BookmarksViewModel {
} }
func generateSmartCollections() async { func generateSmartCollections() async {
guard let claude, !bookmarks.isEmpty else { return } guard !bookmarks.isEmpty else { return }
isGeneratingCollections = true isGeneratingCollections = true
do { do {
let allResponse = try await api.fetchBookmarks(limit: 200) let allResponse = try await api.fetchBookmarks(limit: 200)
@@ -171,7 +165,6 @@ final class BookmarksViewModel {
} }
func enrichAll() async { func enrichAll() async {
guard let claude else { return }
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil } let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
guard !toEnrich.isEmpty else { return } guard !toEnrich.isEmpty else { return }
enrichmentProgress = 0 enrichmentProgress = 0
@@ -191,10 +184,6 @@ final class BookmarksViewModel {
// Silently enrich new bookmarks that lack summaries (background, non-blocking) // Silently enrich new bookmarks that lack summaries (background, non-blocking)
private func startEnrichment() { private func startEnrichment() {
guard claude != nil else {
print("[AI] startEnrichment: skipped (no claude)")
return
}
enrichTask?.cancel() enrichTask?.cancel()
enrichTask = Task { [weak self] in enrichTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -202,7 +191,7 @@ final class BookmarksViewModel {
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich") print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
for i in indices { for i in indices {
guard !Task.isCancelled else { return } 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 { do {
let bm = await MainActor.run { bookmarks[i] } let bm = await MainActor.run { bookmarks[i] }
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)") print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")

View File

@@ -185,16 +185,15 @@ private struct WebViewRepresentable: UIViewRepresentable {
struct BrowserView: View { struct BrowserView: View {
let initialURL: URL let initialURL: URL
let initialTitle: String let initialTitle: String
let claude: ClaudeService? let claude: ClaudeService
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@State private var state: BrowserState @State private var state: BrowserState
@State private var readerMode = false @State private var readerMode = false
@State private var showPodcast = 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.initialURL = url
self.initialTitle = title self.initialTitle = title
self.claude = claude self.claude = claude
@@ -262,10 +261,7 @@ struct BrowserView: View {
Spacer() Spacer()
Button { Button { showPodcast = true } label: {
if claude != nil { showPodcast = true }
else { showAISetupAlert = true }
} label: {
Image(systemName: "headphones") Image(systemName: "headphones")
} }
.disabled(state.isLoading) .disabled(state.isLoading)
@@ -284,14 +280,7 @@ struct BrowserView: View {
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
} }
.sheet(isPresented: $showPodcast) { .sheet(isPresented: $showPodcast) {
if let claude {
PodcastPlayerView(articleUrl: (state.currentURL ?? initialURL).absoluteString, claude: 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.")
}
}
} }

View File

@@ -31,7 +31,6 @@ struct SearchView: View {
.listStyle(.plain) .listStyle(.plain)
.navigationTitle("Search") .navigationTitle("Search")
.toolbar { .toolbar {
if viewModel.claude != nil {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Toggle(isOn: $useSemanticSearch) { Toggle(isOn: $useSemanticSearch) {
Label("Semantic", systemImage: "sparkles") Label("Semantic", systemImage: "sparkles")
@@ -40,7 +39,6 @@ struct SearchView: View {
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() } .onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
} }
} }
}
.overlay { .overlay {
if searchText.isEmpty { if searchText.isEmpty {
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag.")) ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
@@ -72,7 +70,8 @@ struct SearchView: View {
private func scheduleSearch() { private func scheduleSearch() {
semanticResults = nil semanticResults = nil
searchTask?.cancel() searchTask?.cancel()
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return } guard !searchText.isEmpty, useSemanticSearch else { return }
let claude = viewModel.claude
searchTask = Task { searchTask = Task {
try? await Task.sleep(for: .milliseconds(500)) try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }

View File

@@ -4,50 +4,15 @@ struct SettingsView: View {
@Bindable var viewModel: BookmarksViewModel @Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void 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 @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section("AI Features") {
VStack(alignment: .leading, spacing: 6) { Label("Semantic search, auto-tagging, smart collections, and podcast generation are active.", systemImage: "sparkles")
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)) .font(.system(size: 13))
.foregroundStyle(.secondary) .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("Server") { 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)
}
} }