Skip to content

Commit de54d57

Browse files
authored
Saved subscriptions with auto-refresh and change notifications (#2)
Adds a Subscriptions tab so users can save multiple happ:// links (or plain http(s) sub URLs), refresh them, and get notified when configs change (e.g. when servers get blocked). - SavedSubscription model + SubscriptionStore (JSON on disk). - ChangeDetector: pure diff of config-URI snapshots (added/removed). - RefreshService: re-runs extraction per subscription, detects changes, fires notifications; one failure does not abort the batch. - NotificationService: local UNUserNotificationCenter alerts on change. - RefreshCoordinator: foreground + pull-to-refresh, applied on the main actor. - BackgroundRefresh: BGAppRefreshTask (com.happwn.refresh), opportunistic. - ExtractionService now accepts a plain http(s) subscription URL (skips decryption). - UI: Subscriptions list, detail (edit name, per-sub notify, refresh, delete), add sheet; Save button on Extract; Settings 'Обновления' section (notifications, background refresh, min interval). Notifications + background refresh default ON. - Info.plist with UIBackgroundModes + BGTaskSchedulerPermittedIdentifiers; project.yml wired to it. Bump to 1.0.2 (build 3). - Tests: ChangeDetector, SubscriptionStore, RefreshService, plain-URL extraction. Co-authored-by: useruserdev <256019073+useruserdev@users.noreply.github.com>
1 parent f4c5b59 commit de54d57

24 files changed

Lines changed: 1178 additions & 126 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
import BackgroundTasks
3+
4+
/// Registration and scheduling of the opportunistic background refresh task.
5+
/// iOS decides when to actually run it (roughly based on app usage); this is
6+
/// not a guaranteed fixed-interval timer.
7+
enum BackgroundRefresh {
8+
static let taskID = "com.happwn.refresh"
9+
10+
/// Register the task handler. Must be called before the app finishes launching.
11+
static func register(coordinator: @escaping () -> RefreshCoordinator,
12+
minInterval: @escaping () -> TimeInterval) {
13+
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in
14+
guard let task = task as? BGAppRefreshTask else { return }
15+
handle(task: task, coordinator: coordinator(), minInterval: minInterval())
16+
}
17+
}
18+
19+
/// Ask the system to schedule the next refresh no sooner than `minInterval`.
20+
static func schedule(minInterval: TimeInterval) {
21+
let request = BGAppRefreshTaskRequest(identifier: taskID)
22+
request.earliestBeginDate = Date(timeIntervalSinceNow: minInterval)
23+
try? BGTaskScheduler.shared.submit(request)
24+
}
25+
26+
static func cancel() {
27+
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskID)
28+
}
29+
30+
private static func handle(task: BGAppRefreshTask, coordinator: RefreshCoordinator, minInterval: TimeInterval) {
31+
// Always line up the next opportunity.
32+
schedule(minInterval: minInterval)
33+
34+
let work = Task {
35+
await coordinator.refreshAll()
36+
task.setTaskCompleted(success: true)
37+
}
38+
task.expirationHandler = {
39+
work.cancel()
40+
task.setTaskCompleted(success: false)
41+
}
42+
}
43+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
/// Pure diff of two config-URI snapshots. No I/O, fully testable.
4+
enum ChangeDetector {
5+
struct Diff: Equatable {
6+
let added: Int
7+
let removed: Int
8+
var changed: Bool { added > 0 || removed > 0 }
9+
}
10+
11+
static func diff(old: [String], new: [String]) -> Diff {
12+
let oldSet = Set(old)
13+
let newSet = Set(new)
14+
return Diff(
15+
added: newSet.subtracting(oldSet).count,
16+
removed: oldSet.subtracting(newSet).count
17+
)
18+
}
19+
}

ios/happwn/Core/ExtractionService.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ struct ExtractionService {
66
var client: SubscriptionClient = SubscriptionClient()
77

88
func run(link: String, userAgent: String, hwid: String) async throws -> ExtractionResult {
9+
let trimmedLink = link.trimmingCharacters(in: .whitespacesAndNewlines)
10+
11+
// Plain subscription URL: skip decryption, fetch and parse directly.
12+
if trimmedLink.hasPrefix("http://") || trimmedLink.hasPrefix("https://") {
13+
let data = try await client.fetch(urlString: trimmedLink, userAgent: userAgent, hwid: hwid)
14+
let configs = ConfigParser.parse(data)
15+
return ExtractionResult(
16+
mode: "url",
17+
source: trimmedLink,
18+
configs: configs,
19+
rawBody: configs.isEmpty ? String(data: data, encoding: .utf8) : nil
20+
)
21+
}
22+
923
let decrypted = try decryptLink(link)
1024
let value = decrypted.value.trimmingCharacters(in: .whitespacesAndNewlines)
1125

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import Foundation
2+
import UserNotifications
3+
4+
/// Sends a local notification when a subscription's configs change.
5+
/// Injectable so RefreshService can be tested with a spy.
6+
protocol SubscriptionNotifying {
7+
func notifyChange(subscription: SavedSubscription, added: Int, removed: Int) async
8+
}
9+
10+
struct NotificationService: SubscriptionNotifying {
11+
/// userInfo key carrying the subscription id (for deep-linking on tap).
12+
static let subscriptionIDKey = "subscriptionID"
13+
14+
/// Request authorization; returns whether it was granted.
15+
@discardableResult
16+
func requestAuthorization() async -> Bool {
17+
let center = UNUserNotificationCenter.current()
18+
return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
19+
}
20+
21+
func authorizationStatus() async -> UNAuthorizationStatus {
22+
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
23+
}
24+
25+
func notifyChange(subscription: SavedSubscription, added: Int, removed: Int) async {
26+
let content = UNMutableNotificationContent()
27+
content.title = subscription.name
28+
content.body = Self.body(added: added, removed: removed)
29+
content.sound = .default
30+
content.userInfo = [Self.subscriptionIDKey: subscription.id.uuidString]
31+
32+
let request = UNNotificationRequest(
33+
identifier: subscription.id.uuidString,
34+
content: content,
35+
trigger: nil // deliver immediately
36+
)
37+
try? await UNUserNotificationCenter.current().add(request)
38+
}
39+
40+
static func body(added: Int, removed: Int) -> String {
41+
var parts: [String] = []
42+
if added > 0 { parts.append("+\(added)") }
43+
if removed > 0 { parts.append("\(removed)") }
44+
let delta = parts.isEmpty ? "" : " (\(parts.joined(separator: " / ")))"
45+
return "Подписка обновилась\(delta)"
46+
}
47+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import Foundation
2+
import Combine
3+
4+
/// Drives refreshes from the UI (foreground, pull-to-refresh) and from the
5+
/// background task, applying results to the store on the main actor.
6+
@MainActor
7+
final class RefreshCoordinator: ObservableObject {
8+
private let store: SubscriptionStore
9+
private let settings: Settings
10+
private let service: RefreshService
11+
private let notifier: SubscriptionNotifying
12+
13+
@Published private(set) var isRefreshing = false
14+
15+
init(store: SubscriptionStore,
16+
settings: Settings,
17+
service: RefreshService = RefreshService(),
18+
notifier: SubscriptionNotifying = NotificationService()) {
19+
self.store = store
20+
self.settings = settings
21+
self.service = service
22+
self.notifier = notifier
23+
}
24+
25+
func refreshAll() async {
26+
guard !store.items.isEmpty, !isRefreshing else { return }
27+
isRefreshing = true
28+
defer { isRefreshing = false }
29+
let updated = await service.refreshAll(
30+
store.items,
31+
userAgent: settings.userAgent,
32+
hwid: settings.hwid,
33+
notificationsEnabled: settings.notificationsEnabled,
34+
notifier: notifier
35+
)
36+
store.replaceAll(updated)
37+
}
38+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import Foundation
2+
3+
/// Re-fetches saved subscriptions, detects config changes, and fires
4+
/// notifications. The extraction closure is injectable for testing.
5+
struct RefreshService {
6+
/// (link, userAgent, hwid) -> ExtractionResult. Defaults to the real service.
7+
var extract: (String, String, String) async throws -> ExtractionResult = { link, ua, hwid in
8+
try await ExtractionService().run(link: link, userAgent: ua, hwid: hwid)
9+
}
10+
11+
/// Refreshes every subscription, returning updated copies. One failing
12+
/// subscription does not abort the others.
13+
func refreshAll(
14+
_ subs: [SavedSubscription],
15+
userAgent: String,
16+
hwid: String,
17+
notificationsEnabled: Bool,
18+
notifier: SubscriptionNotifying,
19+
now: Date = Date()
20+
) async -> [SavedSubscription] {
21+
var updated: [SavedSubscription] = []
22+
updated.reserveCapacity(subs.count)
23+
for sub in subs {
24+
updated.append(await refresh(sub, userAgent: userAgent, hwid: hwid,
25+
notificationsEnabled: notificationsEnabled,
26+
notifier: notifier, now: now))
27+
}
28+
return updated
29+
}
30+
31+
private func refresh(
32+
_ original: SavedSubscription,
33+
userAgent: String,
34+
hwid: String,
35+
notificationsEnabled: Bool,
36+
notifier: SubscriptionNotifying,
37+
now: Date
38+
) async -> SavedSubscription {
39+
var sub = original
40+
sub.lastCheckedAt = now
41+
do {
42+
let result = try await extract(sub.link, userAgent, hwid)
43+
let newURIs = result.configs.map(\.uri)
44+
let diff = ChangeDetector.diff(old: sub.lastConfigs, new: newURIs)
45+
46+
sub.mode = result.mode
47+
sub.source = result.source
48+
sub.lastError = nil
49+
50+
if diff.changed {
51+
sub.lastConfigs = newURIs
52+
sub.lastChangedAt = now
53+
sub.hasUnseenUpdate = true
54+
if notificationsEnabled && sub.notify {
55+
await notifier.notifyChange(subscription: sub, added: diff.added, removed: diff.removed)
56+
}
57+
}
58+
} catch {
59+
sub.lastError = error.localizedDescription
60+
}
61+
return sub
62+
}
63+
}

ios/happwn/Info.plist

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>CFBundleDisplayName</key>
6+
<string>happwn</string>
7+
<key>CFBundleExecutable</key>
8+
<string>$(EXECUTABLE_NAME)</string>
9+
<key>CFBundleIdentifier</key>
10+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
11+
<key>CFBundleInfoDictionaryVersion</key>
12+
<string>6.0</string>
13+
<key>CFBundleName</key>
14+
<string>$(PRODUCT_NAME)</string>
15+
<key>CFBundlePackageType</key>
16+
<string>APPL</string>
17+
<key>CFBundleShortVersionString</key>
18+
<string>$(MARKETING_VERSION)</string>
19+
<key>CFBundleVersion</key>
20+
<string>$(CURRENT_PROJECT_VERSION)</string>
21+
<key>LSRequiresIPhoneOS</key>
22+
<true/>
23+
<key>UILaunchScreen</key>
24+
<dict/>
25+
<key>UIApplicationSupportsIndirectInputEvents</key>
26+
<true/>
27+
<key>UISupportedInterfaceOrientations</key>
28+
<array>
29+
<string>UIInterfaceOrientationPortrait</string>
30+
<string>UIInterfaceOrientationLandscapeLeft</string>
31+
<string>UIInterfaceOrientationLandscapeRight</string>
32+
</array>
33+
<key>UISupportedInterfaceOrientations~ipad</key>
34+
<array>
35+
<string>UIInterfaceOrientationPortrait</string>
36+
<string>UIInterfaceOrientationPortraitUpsideDown</string>
37+
<string>UIInterfaceOrientationLandscapeLeft</string>
38+
<string>UIInterfaceOrientationLandscapeRight</string>
39+
</array>
40+
<key>UIBackgroundModes</key>
41+
<array>
42+
<string>fetch</string>
43+
</array>
44+
<key>BGTaskSchedulerPermittedIdentifiers</key>
45+
<array>
46+
<string>com.happwn.refresh</string>
47+
</array>
48+
</dict>
49+
</plist>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import Foundation
2+
3+
/// A persisted happ:// subscription the user wants to keep and auto-refresh.
4+
struct SavedSubscription: Codable, Identifiable, Equatable {
5+
var id: UUID = UUID()
6+
var name: String
7+
/// Original happ:// link; re-decrypted on every refresh.
8+
var link: String
9+
/// Snapshot of config URIs from the last successful fetch (for diffing).
10+
var lastConfigs: [String] = []
11+
var mode: String? = nil
12+
/// Last decrypted subscription URL.
13+
var source: String? = nil
14+
var lastCheckedAt: Date? = nil
15+
var lastChangedAt: Date? = nil
16+
/// Set when configs changed and the user hasn't opened the detail yet.
17+
var hasUnseenUpdate: Bool = false
18+
/// Whether change notifications are sent for this subscription.
19+
var notify: Bool = true
20+
/// Last refresh error, if the most recent check failed.
21+
var lastError: String? = nil
22+
23+
var configCount: Int { lastConfigs.count }
24+
25+
/// Host of the source URL, used as a default display name.
26+
static func defaultName(from source: String?) -> String {
27+
if let source, let host = URL(string: source)?.host, !host.isEmpty {
28+
return host
29+
}
30+
return "Подписка"
31+
}
32+
}

ios/happwn/Store/Settings.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import Foundation
22
import Combine
33

4-
/// User-editable request identity and appearance, persisted in UserDefaults.
4+
/// How long the background refresh waits, at minimum, between runs.
5+
/// iOS treats this as a floor, not a guarantee.
6+
enum RefreshInterval: Int, CaseIterable, Identifiable {
7+
case h1 = 1, h3 = 3, h6 = 6, h12 = 12
8+
9+
var id: Int { rawValue }
10+
var seconds: TimeInterval { TimeInterval(rawValue) * 3600 }
11+
var label: String { "\(rawValue) ч" }
12+
}
13+
14+
/// User-editable request identity, appearance, and refresh prefs, persisted in UserDefaults.
515
final class Settings: ObservableObject {
616
@Published var userAgent: String {
717
didSet { defaults.set(userAgent, forKey: Keys.userAgent) }
@@ -15,6 +25,15 @@ final class Settings: ObservableObject {
1525
@Published var appearance: AppAppearance {
1626
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
1727
}
28+
@Published var notificationsEnabled: Bool {
29+
didSet { defaults.set(notificationsEnabled, forKey: Keys.notifications) }
30+
}
31+
@Published var backgroundRefreshEnabled: Bool {
32+
didSet { defaults.set(backgroundRefreshEnabled, forKey: Keys.backgroundRefresh) }
33+
}
34+
@Published var minRefreshInterval: RefreshInterval {
35+
didSet { defaults.set(minRefreshInterval.rawValue, forKey: Keys.refreshInterval) }
36+
}
1837

1938
private let defaults: UserDefaults
2039

@@ -23,6 +42,9 @@ final class Settings: ObservableObject {
2342
static let hwid = "happwn.hwid"
2443
static let accent = "happwn.accent"
2544
static let appearance = "happwn.appearance"
45+
static let notifications = "happwn.notificationsEnabled"
46+
static let backgroundRefresh = "happwn.backgroundRefreshEnabled"
47+
static let refreshInterval = "happwn.minRefreshInterval"
2648
}
2749

2850
init(defaults: UserDefaults = .standard) {
@@ -31,5 +53,10 @@ final class Settings: ObservableObject {
3153
self.hwid = defaults.string(forKey: Keys.hwid) ?? ""
3254
self.accent = AppAccent(rawValue: defaults.string(forKey: Keys.accent) ?? "") ?? .indigo
3355
self.appearance = AppAppearance(rawValue: defaults.string(forKey: Keys.appearance) ?? "") ?? .system
56+
// Default ON so the app notifies about config changes (e.g. blocks) out of the box.
57+
self.notificationsEnabled = defaults.object(forKey: Keys.notifications) as? Bool ?? true
58+
self.backgroundRefreshEnabled = defaults.object(forKey: Keys.backgroundRefresh) as? Bool ?? true
59+
let storedInterval = defaults.integer(forKey: Keys.refreshInterval)
60+
self.minRefreshInterval = RefreshInterval(rawValue: storedInterval) ?? .h3
3461
}
3562
}

0 commit comments

Comments
 (0)