Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions ios/happwn/Core/BackgroundRefresh.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import BackgroundTasks

/// Registration and scheduling of the opportunistic background refresh task.
/// iOS decides when to actually run it (roughly based on app usage); this is
/// not a guaranteed fixed-interval timer.
enum BackgroundRefresh {
static let taskID = "com.happwn.refresh"

/// Register the task handler. Must be called before the app finishes launching.
static func register(coordinator: @escaping () -> RefreshCoordinator,
minInterval: @escaping () -> TimeInterval) {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskID, using: nil) { task in
guard let task = task as? BGAppRefreshTask else { return }
handle(task: task, coordinator: coordinator(), minInterval: minInterval())
}
}

/// Ask the system to schedule the next refresh no sooner than `minInterval`.
static func schedule(minInterval: TimeInterval) {
let request = BGAppRefreshTaskRequest(identifier: taskID)
request.earliestBeginDate = Date(timeIntervalSinceNow: minInterval)
try? BGTaskScheduler.shared.submit(request)
}

static func cancel() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskID)
}

private static func handle(task: BGAppRefreshTask, coordinator: RefreshCoordinator, minInterval: TimeInterval) {
// Always line up the next opportunity.
schedule(minInterval: minInterval)

let work = Task {
await coordinator.refreshAll()
task.setTaskCompleted(success: true)
}
task.expirationHandler = {
work.cancel()
task.setTaskCompleted(success: false)
}
}
}
19 changes: 19 additions & 0 deletions ios/happwn/Core/ChangeDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// Pure diff of two config-URI snapshots. No I/O, fully testable.
enum ChangeDetector {
struct Diff: Equatable {
let added: Int
let removed: Int
var changed: Bool { added > 0 || removed > 0 }
}

static func diff(old: [String], new: [String]) -> Diff {
let oldSet = Set(old)
let newSet = Set(new)
return Diff(
added: newSet.subtracting(oldSet).count,
removed: oldSet.subtracting(newSet).count
)
}
}
14 changes: 14 additions & 0 deletions ios/happwn/Core/ExtractionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ struct ExtractionService {
var client: SubscriptionClient = SubscriptionClient()

func run(link: String, userAgent: String, hwid: String) async throws -> ExtractionResult {
let trimmedLink = link.trimmingCharacters(in: .whitespacesAndNewlines)

// Plain subscription URL: skip decryption, fetch and parse directly.
if trimmedLink.hasPrefix("http://") || trimmedLink.hasPrefix("https://") {
let data = try await client.fetch(urlString: trimmedLink, userAgent: userAgent, hwid: hwid)
let configs = ConfigParser.parse(data)
return ExtractionResult(
mode: "url",
source: trimmedLink,
configs: configs,
rawBody: configs.isEmpty ? String(data: data, encoding: .utf8) : nil
)
}

let decrypted = try decryptLink(link)
let value = decrypted.value.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down
47 changes: 47 additions & 0 deletions ios/happwn/Core/NotificationService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Foundation
import UserNotifications

/// Sends a local notification when a subscription's configs change.
/// Injectable so RefreshService can be tested with a spy.
protocol SubscriptionNotifying {
func notifyChange(subscription: SavedSubscription, added: Int, removed: Int) async
}

struct NotificationService: SubscriptionNotifying {
/// userInfo key carrying the subscription id (for deep-linking on tap).
static let subscriptionIDKey = "subscriptionID"

/// Request authorization; returns whether it was granted.
@discardableResult
func requestAuthorization() async -> Bool {
let center = UNUserNotificationCenter.current()
return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
}

func authorizationStatus() async -> UNAuthorizationStatus {
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
}

func notifyChange(subscription: SavedSubscription, added: Int, removed: Int) async {
let content = UNMutableNotificationContent()
content.title = subscription.name
content.body = Self.body(added: added, removed: removed)
content.sound = .default
content.userInfo = [Self.subscriptionIDKey: subscription.id.uuidString]

let request = UNNotificationRequest(
identifier: subscription.id.uuidString,
content: content,
trigger: nil // deliver immediately
)
try? await UNUserNotificationCenter.current().add(request)
}

static func body(added: Int, removed: Int) -> String {
var parts: [String] = []
if added > 0 { parts.append("+\(added)") }
if removed > 0 { parts.append("−\(removed)") }
let delta = parts.isEmpty ? "" : " (\(parts.joined(separator: " / ")))"
return "Подписка обновилась\(delta)"
}
}
38 changes: 38 additions & 0 deletions ios/happwn/Core/RefreshCoordinator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import Combine

/// Drives refreshes from the UI (foreground, pull-to-refresh) and from the
/// background task, applying results to the store on the main actor.
@MainActor
final class RefreshCoordinator: ObservableObject {
private let store: SubscriptionStore
private let settings: Settings
private let service: RefreshService
private let notifier: SubscriptionNotifying

@Published private(set) var isRefreshing = false

init(store: SubscriptionStore,
settings: Settings,
service: RefreshService = RefreshService(),
notifier: SubscriptionNotifying = NotificationService()) {
self.store = store
self.settings = settings
self.service = service
self.notifier = notifier
}

func refreshAll() async {
guard !store.items.isEmpty, !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
let updated = await service.refreshAll(
store.items,
userAgent: settings.userAgent,
hwid: settings.hwid,
notificationsEnabled: settings.notificationsEnabled,
notifier: notifier
)
store.replaceAll(updated)
}
}
63 changes: 63 additions & 0 deletions ios/happwn/Core/RefreshService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation

/// Re-fetches saved subscriptions, detects config changes, and fires
/// notifications. The extraction closure is injectable for testing.
struct RefreshService {
/// (link, userAgent, hwid) -> ExtractionResult. Defaults to the real service.
var extract: (String, String, String) async throws -> ExtractionResult = { link, ua, hwid in
try await ExtractionService().run(link: link, userAgent: ua, hwid: hwid)
}

/// Refreshes every subscription, returning updated copies. One failing
/// subscription does not abort the others.
func refreshAll(
_ subs: [SavedSubscription],
userAgent: String,
hwid: String,
notificationsEnabled: Bool,
notifier: SubscriptionNotifying,
now: Date = Date()
) async -> [SavedSubscription] {
var updated: [SavedSubscription] = []
updated.reserveCapacity(subs.count)
for sub in subs {
updated.append(await refresh(sub, userAgent: userAgent, hwid: hwid,
notificationsEnabled: notificationsEnabled,
notifier: notifier, now: now))
}
return updated
}

private func refresh(
_ original: SavedSubscription,
userAgent: String,
hwid: String,
notificationsEnabled: Bool,
notifier: SubscriptionNotifying,
now: Date
) async -> SavedSubscription {
var sub = original
sub.lastCheckedAt = now
do {
let result = try await extract(sub.link, userAgent, hwid)
let newURIs = result.configs.map(\.uri)
let diff = ChangeDetector.diff(old: sub.lastConfigs, new: newURIs)

sub.mode = result.mode
sub.source = result.source
sub.lastError = nil

if diff.changed {
sub.lastConfigs = newURIs
sub.lastChangedAt = now
sub.hasUnseenUpdate = true
if notificationsEnabled && sub.notify {
await notifier.notifyChange(subscription: sub, added: diff.added, removed: diff.removed)
}
}
} catch {
sub.lastError = error.localizedDescription
}
return sub
}
}
49 changes: 49 additions & 0 deletions ios/happwn/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>happwn</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.happwn.refresh</string>
</array>
</dict>
</plist>
32 changes: 32 additions & 0 deletions ios/happwn/Models/SavedSubscription.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// A persisted happ:// subscription the user wants to keep and auto-refresh.
struct SavedSubscription: Codable, Identifiable, Equatable {
var id: UUID = UUID()
var name: String
/// Original happ:// link; re-decrypted on every refresh.
var link: String
/// Snapshot of config URIs from the last successful fetch (for diffing).
var lastConfigs: [String] = []
var mode: String? = nil
/// Last decrypted subscription URL.
var source: String? = nil
var lastCheckedAt: Date? = nil
var lastChangedAt: Date? = nil
/// Set when configs changed and the user hasn't opened the detail yet.
var hasUnseenUpdate: Bool = false
/// Whether change notifications are sent for this subscription.
var notify: Bool = true
/// Last refresh error, if the most recent check failed.
var lastError: String? = nil

var configCount: Int { lastConfigs.count }

/// Host of the source URL, used as a default display name.
static func defaultName(from source: String?) -> String {
if let source, let host = URL(string: source)?.host, !host.isEmpty {
return host
}
return "Подписка"
}
}
29 changes: 28 additions & 1 deletion ios/happwn/Store/Settings.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import Foundation
import Combine

/// User-editable request identity and appearance, persisted in UserDefaults.
/// How long the background refresh waits, at minimum, between runs.
/// iOS treats this as a floor, not a guarantee.
enum RefreshInterval: Int, CaseIterable, Identifiable {
case h1 = 1, h3 = 3, h6 = 6, h12 = 12

var id: Int { rawValue }
var seconds: TimeInterval { TimeInterval(rawValue) * 3600 }
var label: String { "\(rawValue) ч" }
}

/// User-editable request identity, appearance, and refresh prefs, persisted in UserDefaults.
final class Settings: ObservableObject {
@Published var userAgent: String {
didSet { defaults.set(userAgent, forKey: Keys.userAgent) }
Expand All @@ -15,6 +25,15 @@ final class Settings: ObservableObject {
@Published var appearance: AppAppearance {
didSet { defaults.set(appearance.rawValue, forKey: Keys.appearance) }
}
@Published var notificationsEnabled: Bool {
didSet { defaults.set(notificationsEnabled, forKey: Keys.notifications) }
}
@Published var backgroundRefreshEnabled: Bool {
didSet { defaults.set(backgroundRefreshEnabled, forKey: Keys.backgroundRefresh) }
}
@Published var minRefreshInterval: RefreshInterval {
didSet { defaults.set(minRefreshInterval.rawValue, forKey: Keys.refreshInterval) }
}

private let defaults: UserDefaults

Expand All @@ -23,6 +42,9 @@ final class Settings: ObservableObject {
static let hwid = "happwn.hwid"
static let accent = "happwn.accent"
static let appearance = "happwn.appearance"
static let notifications = "happwn.notificationsEnabled"
static let backgroundRefresh = "happwn.backgroundRefreshEnabled"
static let refreshInterval = "happwn.minRefreshInterval"
}

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