Skip to content

Commit 3383f46

Browse files
committed
Refactor settings and add custom lists
1 parent 21c9991 commit 3383f46

File tree

5 files changed

+362
-69
lines changed

5 files changed

+362
-69
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
3+
struct FilterSource: Identifiable, Codable, Hashable {
4+
let id: UUID
5+
var name: String
6+
var url: String
7+
var isEnabled: Bool
8+
9+
init(id: UUID = UUID(), name: String, url: String, isEnabled: Bool = true) {
10+
self.id = id
11+
self.name = name
12+
self.url = url
13+
self.isEnabled = isEnabled
14+
}
15+
16+
static let defaultSource = FilterSource(
17+
name: "LegitimateURLShortener",
18+
url: "https://raw.githubusercontent.com/DandelionSprout/adfilt/master/LegitimateURLShortener.txt"
19+
)
20+
}

nutcracker/MenuBarView.swift

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import ServiceManagement
21
import SwiftUI
32

43
struct MenuBarView: View {
54
@Bindable var clipboardMonitor: ClipboardMonitor
65
var filterListStore: FilterListStore
76

8-
@State private var launchAtLogin = SMAppService.mainApp.status == .enabled
7+
@Environment(\.openSettings) private var openSettings
98

109
var body: some View {
1110
Toggle("Enabled", isOn: $clipboardMonitor.isEnabled)
@@ -45,26 +44,15 @@ struct MenuBarView: View {
4544

4645
Button("Refresh Rules") {
4746
Task {
48-
await filterListStore.fetchFromRemote()
47+
await filterListStore.refreshAllSources()
4948
}
5049
}
5150
.disabled(filterListStore.isLoading)
5251

53-
Divider()
54-
55-
Toggle("Launch at Login", isOn: $launchAtLogin)
56-
.toggleStyle(.checkbox)
57-
.onChange(of: launchAtLogin) {
58-
do {
59-
if launchAtLogin {
60-
try SMAppService.mainApp.register()
61-
} else {
62-
try SMAppService.mainApp.unregister()
63-
}
64-
} catch {
65-
launchAtLogin = SMAppService.mainApp.status == .enabled
66-
}
67-
}
52+
Button("Settings…") {
53+
NSApp.activate()
54+
openSettings()
55+
}
6856

6957
Divider()
7058

nutcracker/Services/FilterListStore.swift

Lines changed: 164 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,78 +7,191 @@ final class FilterListStore {
77
private(set) var isLoading = false
88
private(set) var lastUpdated: Date?
99
private(set) var error: String?
10-
11-
private let listURL = URL(string: "https://raw.githubusercontent.com/DandelionSprout/adfilt/master/LegitimateURLShortener.txt")!
10+
11+
private(set) var sources: [FilterSource] = []
12+
var customRulesText: String = ""
13+
1214
private let parser = FilterListParser()
1315
private let logger = Logger(subsystem: "dev.sweet.diva.nutcracker", category: "FilterListStore")
14-
16+
17+
// MARK: - Cache paths
18+
1519
private var cacheDirectory: URL {
1620
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
1721
return appSupport.appendingPathComponent("nutcracker", isDirectory: true)
1822
}
19-
20-
private var cacheFileURL: URL {
21-
cacheDirectory.appendingPathComponent("LegitimateURLShortener.txt")
23+
24+
private var sourcesFileURL: URL {
25+
cacheDirectory.appendingPathComponent("sources.json")
2226
}
23-
27+
28+
private var customRulesFileURL: URL {
29+
cacheDirectory.appendingPathComponent("customRules.txt")
30+
}
31+
2432
private var metadataURL: URL {
2533
cacheDirectory.appendingPathComponent("metadata.json")
2634
}
27-
35+
36+
private func cacheFileURL(for source: FilterSource) -> URL {
37+
cacheDirectory.appendingPathComponent("cache_\(source.id.uuidString).txt")
38+
}
39+
40+
// MARK: - Public API
41+
2842
func loadOrFetch() async {
29-
// Try loading from cache first
30-
if let cached = loadFromCache() {
31-
rules = parser.parse(cached)
32-
logger.info("Loaded \(self.rules.count) rules from cache")
33-
34-
// Check if refresh needed (older than 24h)
35-
if let lastUpdated, Date().timeIntervalSince(lastUpdated) < 86400 {
36-
return
37-
}
43+
loadSavedState()
44+
45+
if sources.isEmpty {
46+
sources.append(.defaultSource)
47+
saveSources()
3848
}
39-
40-
await fetchFromRemote()
49+
50+
reparseRules()
51+
logger.info("Loaded \(self.rules.count) rules from cache")
52+
53+
if let lastUpdated, Date().timeIntervalSince(lastUpdated) < 86400 {
54+
return
55+
}
56+
57+
await refreshAllSources()
4158
}
42-
43-
func fetchFromRemote() async {
59+
60+
func refreshAllSources() async {
4461
isLoading = true
4562
error = nil
4663
defer { isLoading = false }
47-
48-
do {
49-
let (data, _) = try await URLSession.shared.data(from: listURL)
50-
guard let text = String(data: data, encoding: .utf8) else {
51-
error = "Invalid encoding"
52-
return
64+
65+
for source in sources where source.isEnabled {
66+
do {
67+
try await fetchSource(source)
68+
} catch {
69+
self.error = "Some lists failed to update"
70+
logger.error("Failed to fetch \(source.name): \(error.localizedDescription)")
5371
}
54-
55-
// Save to cache
56-
try saveToCache(text)
57-
58-
// Parse rules
59-
let newRules = parser.parse(text)
60-
rules = newRules
61-
lastUpdated = Date()
62-
saveMetadata()
63-
64-
logger.info("Fetched and parsed \(newRules.count) rules from remote")
65-
} catch {
66-
self.error = error.localizedDescription
67-
logger.error("Failed to fetch filter list: \(error.localizedDescription)")
6872
}
73+
74+
lastUpdated = Date()
75+
saveMetadata()
76+
reparseRules()
77+
logger.info("Refreshed all sources, \(self.rules.count) total rules")
6978
}
70-
71-
private func loadFromCache() -> String? {
72-
loadMetadata()
73-
guard FileManager.default.fileExists(atPath: cacheFileURL.path) else { return nil }
74-
return try? String(contentsOf: cacheFileURL, encoding: .utf8)
79+
80+
func addSource(name: String, url: String) {
81+
let source = FilterSource(name: name, url: url)
82+
sources.append(source)
83+
saveSources()
84+
85+
Task {
86+
isLoading = true
87+
defer { isLoading = false }
88+
do {
89+
try await fetchSource(source)
90+
reparseRules()
91+
} catch {
92+
logger.error("Failed to fetch new source \(name): \(error.localizedDescription)")
93+
}
94+
}
95+
}
96+
97+
func removeSource(id: UUID) {
98+
guard let index = sources.firstIndex(where: { $0.id == id }) else { return }
99+
let source = sources[index]
100+
try? FileManager.default.removeItem(at: cacheFileURL(for: source))
101+
sources.remove(at: index)
102+
saveSources()
103+
reparseRules()
104+
}
105+
106+
func toggleSource(id: UUID) {
107+
guard let index = sources.firstIndex(where: { $0.id == id }) else { return }
108+
sources[index].isEnabled.toggle()
109+
saveSources()
110+
reparseRules()
111+
}
112+
113+
func applyCustomRules() {
114+
saveCustomRules()
115+
reparseRules()
116+
}
117+
118+
// MARK: - Parsing
119+
120+
private func reparseRules() {
121+
var allRules: [RemoveParamRule] = []
122+
123+
for source in sources where source.isEnabled {
124+
if let cached = try? String(contentsOf: cacheFileURL(for: source), encoding: .utf8) {
125+
allRules.append(contentsOf: parser.parse(cached))
126+
}
127+
}
128+
129+
let trimmed = customRulesText.trimmingCharacters(in: .whitespacesAndNewlines)
130+
if !trimmed.isEmpty {
131+
allRules.append(contentsOf: parser.parse(customRulesText))
132+
}
133+
134+
rules = allRules
135+
}
136+
137+
// MARK: - Networking
138+
139+
private func fetchSource(_ source: FilterSource) async throws {
140+
guard let url = URL(string: source.url) else {
141+
throw URLError(.badURL)
142+
}
143+
144+
let (data, _) = try await URLSession.shared.data(from: url)
145+
guard let text = String(data: data, encoding: .utf8) else {
146+
throw URLError(.cannotDecodeContentData)
147+
}
148+
149+
try ensureCacheDirectory()
150+
try text.write(to: cacheFileURL(for: source), atomically: true, encoding: .utf8)
75151
}
76-
77-
private func saveToCache(_ text: String) throws {
152+
153+
// MARK: - Persistence
154+
155+
private func ensureCacheDirectory() throws {
78156
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
79-
try text.write(to: cacheFileURL, atomically: true, encoding: .utf8)
80157
}
81-
158+
159+
private func loadSavedState() {
160+
loadMetadata()
161+
loadSources()
162+
loadCustomRules()
163+
}
164+
165+
private func loadSources() {
166+
guard let data = try? Data(contentsOf: sourcesFileURL),
167+
let decoded = try? JSONDecoder().decode([FilterSource].self, from: data)
168+
else { return }
169+
sources = decoded
170+
}
171+
172+
private func saveSources() {
173+
do {
174+
try ensureCacheDirectory()
175+
let data = try JSONEncoder().encode(sources)
176+
try data.write(to: sourcesFileURL, options: .atomic)
177+
} catch {
178+
logger.error("Failed to save sources: \(error.localizedDescription)")
179+
}
180+
}
181+
182+
private func loadCustomRules() {
183+
customRulesText = (try? String(contentsOf: customRulesFileURL, encoding: .utf8)) ?? ""
184+
}
185+
186+
private func saveCustomRules() {
187+
do {
188+
try ensureCacheDirectory()
189+
try customRulesText.write(to: customRulesFileURL, atomically: true, encoding: .utf8)
190+
} catch {
191+
logger.error("Failed to save custom rules: \(error.localizedDescription)")
192+
}
193+
}
194+
82195
private func saveMetadata() {
83196
let meta: [String: String] = [
84197
"lastUpdated": ISO8601DateFormatter().string(from: lastUpdated ?? Date())
@@ -87,7 +200,7 @@ final class FilterListStore {
87200
try? data.write(to: metadataURL)
88201
}
89202
}
90-
203+
91204
private func loadMetadata() {
92205
guard let data = try? Data(contentsOf: metadataURL),
93206
let meta = try? JSONDecoder().decode([String: String].self, from: data),

0 commit comments

Comments
 (0)