Replace manual backend config with auto JWT auth
Some checks failed
CI / build-and-deploy (push) Failing after 6s
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:
@@ -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()
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
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)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user