diff --git a/ios/happwn/Core/BackgroundRefresh.swift b/ios/happwn/Core/BackgroundRefresh.swift
new file mode 100644
index 0000000..fa1e69e
--- /dev/null
+++ b/ios/happwn/Core/BackgroundRefresh.swift
@@ -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)
+ }
+ }
+}
diff --git a/ios/happwn/Core/ChangeDetector.swift b/ios/happwn/Core/ChangeDetector.swift
new file mode 100644
index 0000000..a979811
--- /dev/null
+++ b/ios/happwn/Core/ChangeDetector.swift
@@ -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
+ )
+ }
+}
diff --git a/ios/happwn/Core/ExtractionService.swift b/ios/happwn/Core/ExtractionService.swift
index 340b435..74f122d 100644
--- a/ios/happwn/Core/ExtractionService.swift
+++ b/ios/happwn/Core/ExtractionService.swift
@@ -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)
diff --git a/ios/happwn/Core/NotificationService.swift b/ios/happwn/Core/NotificationService.swift
new file mode 100644
index 0000000..e7ceffc
--- /dev/null
+++ b/ios/happwn/Core/NotificationService.swift
@@ -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)"
+ }
+}
diff --git a/ios/happwn/Core/RefreshCoordinator.swift b/ios/happwn/Core/RefreshCoordinator.swift
new file mode 100644
index 0000000..5002d60
--- /dev/null
+++ b/ios/happwn/Core/RefreshCoordinator.swift
@@ -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)
+ }
+}
diff --git a/ios/happwn/Core/RefreshService.swift b/ios/happwn/Core/RefreshService.swift
new file mode 100644
index 0000000..e2a1dd1
--- /dev/null
+++ b/ios/happwn/Core/RefreshService.swift
@@ -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
+ }
+}
diff --git a/ios/happwn/Info.plist b/ios/happwn/Info.plist
new file mode 100644
index 0000000..bd4609b
--- /dev/null
+++ b/ios/happwn/Info.plist
@@ -0,0 +1,49 @@
+
+
+
+
+ CFBundleDisplayName
+ happwn
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ UILaunchScreen
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIBackgroundModes
+
+ fetch
+
+ BGTaskSchedulerPermittedIdentifiers
+
+ com.happwn.refresh
+
+
+
diff --git a/ios/happwn/Models/SavedSubscription.swift b/ios/happwn/Models/SavedSubscription.swift
new file mode 100644
index 0000000..250d153
--- /dev/null
+++ b/ios/happwn/Models/SavedSubscription.swift
@@ -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 "Подписка"
+ }
+}
diff --git a/ios/happwn/Store/Settings.swift b/ios/happwn/Store/Settings.swift
index 51ffe12..ae51931 100644
--- a/ios/happwn/Store/Settings.swift
+++ b/ios/happwn/Store/Settings.swift
@@ -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) }
@@ -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
@@ -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) {
@@ -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
}
}
diff --git a/ios/happwn/Store/SubscriptionStore.swift b/ios/happwn/Store/SubscriptionStore.swift
new file mode 100644
index 0000000..34d500c
--- /dev/null
+++ b/ios/happwn/Store/SubscriptionStore.swift
@@ -0,0 +1,77 @@
+import Foundation
+import Combine
+
+/// Persists the list of saved subscriptions as JSON on disk.
+/// Mutated on the main thread (from views and the main-actor RefreshCoordinator).
+final class SubscriptionStore: ObservableObject {
+ @Published private(set) var items: [SavedSubscription] = []
+
+ private let fileURL: URL
+
+ /// Defaults to Application Support; tests inject a temp file.
+ init(fileURL: URL? = nil) {
+ self.fileURL = fileURL ?? Self.defaultFileURL()
+ load()
+ }
+
+ // MARK: Mutations
+
+ func add(_ sub: SavedSubscription) {
+ items.append(sub)
+ save()
+ }
+
+ func remove(_ id: UUID) {
+ items.removeAll { $0.id == id }
+ save()
+ }
+
+ func remove(atOffsets offsets: IndexSet) {
+ items.remove(atOffsets: offsets)
+ save()
+ }
+
+ func update(_ sub: SavedSubscription) {
+ guard let i = items.firstIndex(where: { $0.id == sub.id }) else { return }
+ items[i] = sub
+ save()
+ }
+
+ /// Replace the whole list (used after a batch refresh).
+ func replaceAll(_ updated: [SavedSubscription]) {
+ items = updated
+ save()
+ }
+
+ /// Clear the unseen-update badge after the user views a subscription.
+ func markSeen(_ id: UUID) {
+ guard let i = items.firstIndex(where: { $0.id == id }), items[i].hasUnseenUpdate else { return }
+ items[i].hasUnseenUpdate = false
+ save()
+ }
+
+ func binding(for id: UUID) -> SavedSubscription? {
+ items.first { $0.id == id }
+ }
+
+ // MARK: Persistence
+
+ private func load() {
+ guard let data = try? Data(contentsOf: fileURL) else { return }
+ if let decoded = try? JSONDecoder().decode([SavedSubscription].self, from: data) {
+ items = decoded
+ }
+ }
+
+ private func save() {
+ guard let data = try? JSONEncoder().encode(items) else { return }
+ try? data.write(to: fileURL, options: .atomic)
+ }
+
+ private static func defaultFileURL() -> URL {
+ let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir.appendingPathComponent("subscriptions.json")
+ }
+}
diff --git a/ios/happwn/UI/AddSubscriptionView.swift b/ios/happwn/UI/AddSubscriptionView.swift
new file mode 100644
index 0000000..90e9d5e
--- /dev/null
+++ b/ios/happwn/UI/AddSubscriptionView.swift
@@ -0,0 +1,96 @@
+import SwiftUI
+import UIKit
+
+struct AddSubscriptionView: View {
+ @EnvironmentObject private var store: SubscriptionStore
+ @EnvironmentObject private var settings: Settings
+ @Environment(\.dismiss) private var dismiss
+
+ @State private var link = ""
+ @State private var name = ""
+ @State private var isSaving = false
+ @State private var error: String?
+
+ private var canSave: Bool {
+ !link.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSaving
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ TextField("happ://… или https://…", text: $link, axis: .vertical)
+ .font(.system(.callout, design: .monospaced))
+ .lineLimit(2...5)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ Button {
+ if let s = UIPasteboard.general.string { link = s }
+ } label: {
+ Label("Вставить из буфера", systemImage: "doc.on.clipboard")
+ }
+ } header: {
+ Text("Ссылка")
+ } footer: {
+ Text("happ://-ссылка или обычный URL подписки. happwn вытащит конфиги и сообщит, когда они изменятся.")
+ }
+
+ Section("Название (необязательно)") {
+ TextField("например, мой провайдер", text: $name)
+ .autocorrectionDisabled()
+ }
+
+ if let error {
+ Section {
+ Label(error, systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.orange)
+ .font(.callout)
+ }
+ }
+ }
+ .navigationTitle("Новая подписка")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Отмена") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ if isSaving {
+ ProgressView()
+ } else {
+ Button("Сохранить") { save() }
+ .disabled(!canSave)
+ }
+ }
+ }
+ }
+ }
+
+ private func save() {
+ let trimmed = link.trimmingCharacters(in: .whitespacesAndNewlines)
+ error = nil
+ isSaving = true
+ Task {
+ do {
+ let result = try await ExtractionService().run(
+ link: trimmed, userAgent: settings.userAgent, hwid: settings.hwid)
+ let now = Date()
+ let chosenName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let sub = SavedSubscription(
+ name: chosenName.isEmpty ? SavedSubscription.defaultName(from: result.source) : chosenName,
+ link: trimmed,
+ lastConfigs: result.configs.map(\.uri),
+ mode: result.mode,
+ source: result.source,
+ lastCheckedAt: now,
+ lastChangedAt: now
+ )
+ store.add(sub)
+ dismiss()
+ } catch {
+ self.error = error.localizedDescription
+ }
+ isSaving = false
+ }
+ }
+}
diff --git a/ios/happwn/UI/ConfigComponents.swift b/ios/happwn/UI/ConfigComponents.swift
new file mode 100644
index 0000000..0dc55e3
--- /dev/null
+++ b/ios/happwn/UI/ConfigComponents.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+import UIKit
+
+/// Card showing a decrypted subscription source URL with a copy button.
+struct SourceCard: View {
+ let source: String
+ @State private var copied = false
+
+ var body: some View {
+ GroupedCard {
+ HStack(spacing: 12) {
+ IconBadge(systemName: "link", color: .accentColor)
+ Text(source)
+ .font(.system(.caption, design: .monospaced))
+ .lineLimit(2)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Button {
+ UIPasteboard.general.string = source
+ Haptics.tap()
+ copied = true
+ Task {
+ try? await Task.sleep(nanoseconds: 1_400_000_000)
+ copied = false
+ }
+ } label: {
+ Image(systemName: copied ? "checkmark" : "doc.on.doc")
+ .foregroundStyle(copied ? Color.green : Color.accentColor)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(14)
+ }
+ }
+}
+
+/// Inset-grouped list of config URIs with per-protocol icons and tap-to-copy.
+struct ConfigListCard: View {
+ let uris: [String]
+ @State private var copiedIndex: Int?
+
+ var body: some View {
+ GroupedCard {
+ ForEach(Array(uris.enumerated()), id: \.offset) { index, uri in
+ if index > 0 {
+ Divider().padding(.leading, 55)
+ }
+ row(index: index, uri: uri)
+ }
+ }
+ }
+
+ private func row(index: Int, uri: String) -> some View {
+ let scheme = ConfigEntry(uri: uri).scheme
+ return Button {
+ UIPasteboard.general.string = uri
+ Haptics.tap()
+ copiedIndex = index
+ Task {
+ try? await Task.sleep(nanoseconds: 1_400_000_000)
+ if copiedIndex == index { copiedIndex = nil }
+ }
+ } label: {
+ HStack(spacing: 12) {
+ IconBadge(systemName: "",
+ color: ProtocolStyle.color(for: scheme),
+ text: ProtocolStyle.badge(for: scheme))
+ Text(uri)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ .lineLimit(2)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ Image(systemName: copiedIndex == index ? "checkmark" : "doc.on.doc")
+ .font(.footnote)
+ .foregroundStyle(copiedIndex == index ? Color.green : Color.secondary)
+ }
+ .padding(14)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+/// Accent pill showing the crypt mode and config count, plus copy-all / share.
+struct ConfigsHeaderActions: View {
+ let mode: String
+ let uris: [String]
+
+ private var joined: String { uris.joined(separator: "\n") }
+
+ var body: some View {
+ HStack(spacing: 14) {
+ Text("\(mode) · \(uris.count)")
+ .font(.caption.weight(.bold))
+ .foregroundStyle(Color.accentColor)
+ .padding(.horizontal, 9)
+ .padding(.vertical, 3)
+ .background(Color.accentColor.opacity(0.14), in: Capsule())
+ if !uris.isEmpty {
+ Button {
+ UIPasteboard.general.string = joined
+ Haptics.tap()
+ } label: {
+ Text("Копировать всё").font(.caption.weight(.semibold))
+ }
+ ShareLink(item: joined) {
+ Image(systemName: "square.and.arrow.up").font(.caption)
+ }
+ }
+ }
+ }
+}
diff --git a/ios/happwn/UI/ExtractView.swift b/ios/happwn/UI/ExtractView.swift
index bc00419..5f28a1a 100644
--- a/ios/happwn/UI/ExtractView.swift
+++ b/ios/happwn/UI/ExtractView.swift
@@ -3,8 +3,10 @@ import UIKit
struct ExtractView: View {
@EnvironmentObject private var settings: Settings
+ @EnvironmentObject private var store: SubscriptionStore
@StateObject private var vm = ExtractionViewModel()
@FocusState private var fieldFocused: Bool
+ @State private var savedLink: String?
private var isLoading: Bool {
if case .loading = vm.state { return true }
@@ -14,7 +16,7 @@ struct ExtractView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
- Text("Вставь happ://-ссылку — расшифрую и достану конфиги")
+ Text("Вставь happ://-ссылку или URL подписки — достану конфиги")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
@@ -69,7 +71,10 @@ struct ExtractView: View {
.frame(maxWidth: .infinity)
.padding(.top, 40)
case .success(let result):
- ResultsView(result: result)
+ VStack(alignment: .leading, spacing: 18) {
+ ResultsView(result: result)
+ saveButton(result)
+ }
case .failure(let message):
errorCard(message)
}
@@ -87,6 +92,33 @@ struct ExtractView: View {
.padding(.top, 48)
}
+ @ViewBuilder private func saveButton(_ result: ExtractionViewModel.ExtractionResultView) -> some View {
+ let isSaved = savedLink == vm.link
+ Button {
+ let now = Date()
+ let sub = SavedSubscription(
+ name: SavedSubscription.defaultName(from: result.source),
+ link: vm.link,
+ lastConfigs: result.configs.map(\.uri),
+ mode: result.mode,
+ source: result.source,
+ lastCheckedAt: now,
+ lastChangedAt: now
+ )
+ store.add(sub)
+ savedLink = vm.link
+ Haptics.tap()
+ } label: {
+ Label(isSaved ? "Подписка сохранена" : "Сохранить подписку",
+ systemImage: isSaved ? "checkmark.circle.fill" : "plus.circle")
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 13)
+ }
+ .buttonStyle(.bordered)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .disabled(isSaved)
+ }
+
private func errorCard(_ message: String) -> some View {
GroupedCard {
HStack(alignment: .top, spacing: 12) {
diff --git a/ios/happwn/UI/ResultsView.swift b/ios/happwn/UI/ResultsView.swift
index 01ef385..e261dce 100644
--- a/ios/happwn/UI/ResultsView.swift
+++ b/ios/happwn/UI/ResultsView.swift
@@ -1,126 +1,32 @@
import SwiftUI
-import UIKit
struct ResultsView: View {
let result: ExtractionViewModel.ExtractionResultView
- @State private var copiedID: UUID?
- @State private var sourceCopied = false
-
- private var allConfigs: String {
- result.configs.map(\.uri).joined(separator: "\n")
- }
+ private var uris: [String] { result.configs.map(\.uri) }
var body: some View {
VStack(alignment: .leading, spacing: 18) {
if !result.source.isEmpty {
- sourceSection
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel("Источник подписки")
+ SourceCard(source: result.source)
+ }
}
if let raw = result.rawBody {
rawSection(raw)
} else {
- configsSection
- }
- }
- }
-
- // MARK: Source
-
- private var sourceSection: some View {
- VStack(alignment: .leading, spacing: Layout.rowSpacing) {
- SectionLabel("Источник подписки")
- GroupedCard {
- HStack(spacing: 12) {
- IconBadge(systemName: "link", color: .accentColor)
- Text(result.source)
- .font(.system(.caption, design: .monospaced))
- .lineLimit(2)
- .textSelection(.enabled)
- .frame(maxWidth: .infinity, alignment: .leading)
- Button {
- UIPasteboard.general.string = result.source
- Haptics.tap()
- sourceCopied = true
- resetSourceCopied()
- } label: {
- Image(systemName: sourceCopied ? "checkmark" : "doc.on.doc")
- .foregroundStyle(sourceCopied ? Color.green : Color.accentColor)
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel("Конфиги") {
+ ConfigsHeaderActions(mode: result.mode, uris: uris)
}
- .buttonStyle(.plain)
+ ConfigListCard(uris: uris)
}
- .padding(14)
}
}
}
- // MARK: Configs
-
- private var configsSection: some View {
- VStack(alignment: .leading, spacing: Layout.rowSpacing) {
- SectionLabel("Конфиги") {
- HStack(spacing: 14) {
- Text("\(result.mode) · \(result.configs.count)")
- .font(.caption.weight(.bold))
- .foregroundStyle(Color.accentColor)
- .padding(.horizontal, 9)
- .padding(.vertical, 3)
- .background(Color.accentColor.opacity(0.14), in: Capsule())
- if !result.configs.isEmpty {
- Button {
- UIPasteboard.general.string = allConfigs
- Haptics.tap()
- } label: {
- Text("Копировать всё").font(.caption.weight(.semibold))
- }
- ShareLink(item: allConfigs) {
- Image(systemName: "square.and.arrow.up").font(.caption)
- }
- }
- }
- }
-
- GroupedCard {
- ForEach(Array(result.configs.enumerated()), id: \.element.id) { index, config in
- if index > 0 {
- Divider().padding(.leading, 55)
- }
- configRow(config)
- }
- }
- }
- }
-
- private func configRow(_ config: ConfigEntry) -> some View {
- Button {
- UIPasteboard.general.string = config.uri
- Haptics.tap()
- copiedID = config.id
- resetCopied(config.id)
- } label: {
- HStack(spacing: 12) {
- IconBadge(
- systemName: "",
- color: ProtocolStyle.color(for: config.scheme),
- text: ProtocolStyle.badge(for: config.scheme)
- )
- Text(config.uri)
- .font(.system(.caption, design: .monospaced))
- .foregroundStyle(.primary)
- .lineLimit(2)
- .frame(maxWidth: .infinity, alignment: .leading)
- Image(systemName: copiedID == config.id ? "checkmark" : "doc.on.doc")
- .font(.footnote)
- .foregroundStyle(copiedID == config.id ? Color.green : Color.secondary)
- }
- .padding(14)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- }
-
- // MARK: Raw fallback
-
private func rawSection(_ raw: String) -> some View {
VStack(alignment: .leading, spacing: Layout.rowSpacing) {
SectionLabel("Сырой ответ")
@@ -137,20 +43,4 @@ struct ResultsView: View {
}
}
}
-
- // MARK: Transient copy feedback
-
- private func resetCopied(_ id: UUID) {
- Task {
- try? await Task.sleep(nanoseconds: 1_400_000_000)
- if copiedID == id { copiedID = nil }
- }
- }
-
- private func resetSourceCopied() {
- Task {
- try? await Task.sleep(nanoseconds: 1_400_000_000)
- sourceCopied = false
- }
- }
}
diff --git a/ios/happwn/UI/RootView.swift b/ios/happwn/UI/RootView.swift
index e9fb78d..7ebaaf3 100644
--- a/ios/happwn/UI/RootView.swift
+++ b/ios/happwn/UI/RootView.swift
@@ -1,11 +1,18 @@
import SwiftUI
-/// Two-tab shell. On iOS 26 SDK the native TabView renders the Liquid Glass
+/// Three-tab shell. On iOS 26 SDK the native TabView renders the Liquid Glass
/// floating tab bar automatically; on earlier systems it falls back to the
/// standard bar.
struct RootView: View {
var body: some View {
TabView {
+ NavigationStack {
+ SubscriptionsListView()
+ }
+ .tabItem {
+ Label("Подписки", systemImage: "square.stack.3d.up")
+ }
+
NavigationStack {
ExtractView()
}
diff --git a/ios/happwn/UI/SettingsView.swift b/ios/happwn/UI/SettingsView.swift
index 3ef7986..02911f1 100644
--- a/ios/happwn/UI/SettingsView.swift
+++ b/ios/happwn/UI/SettingsView.swift
@@ -30,6 +30,32 @@ struct SettingsView: View {
.pickerStyle(.segmented)
}
+ Section {
+ Toggle("Уведомления об обновлениях", isOn: $settings.notificationsEnabled)
+ .onChange(of: settings.notificationsEnabled) { enabled in
+ if enabled {
+ Task { await NotificationService().requestAuthorization() }
+ }
+ }
+ Toggle("Фоновое обновление", isOn: $settings.backgroundRefreshEnabled)
+ .onChange(of: settings.backgroundRefreshEnabled) { enabled in
+ if enabled {
+ BackgroundRefresh.schedule(minInterval: settings.minRefreshInterval.seconds)
+ } else {
+ BackgroundRefresh.cancel()
+ }
+ }
+ Picker("Проверять не чаще чем", selection: $settings.minRefreshInterval) {
+ ForEach(RefreshInterval.allCases) { interval in
+ Text(interval.label).tag(interval)
+ }
+ }
+ } header: {
+ Text("Обновления")
+ } footer: {
+ Text("iOS запускает фоновое обновление по своему усмотрению, ориентируясь на то, как часто ты открываешь приложение — точный интервал не гарантирован.")
+ }
+
Section {
NavigationLink {
AboutView()
diff --git a/ios/happwn/UI/SubscriptionDetailView.swift b/ios/happwn/UI/SubscriptionDetailView.swift
new file mode 100644
index 0000000..91e1642
--- /dev/null
+++ b/ios/happwn/UI/SubscriptionDetailView.swift
@@ -0,0 +1,140 @@
+import SwiftUI
+
+struct SubscriptionDetailView: View {
+ @EnvironmentObject private var store: SubscriptionStore
+ @EnvironmentObject private var coordinator: RefreshCoordinator
+ @Environment(\.dismiss) private var dismiss
+
+ let id: UUID
+
+ private var sub: SavedSubscription? { store.items.first { $0.id == id } }
+
+ var body: some View {
+ Group {
+ if let sub {
+ content(sub)
+ } else {
+ Color.clear.onAppear { dismiss() }
+ }
+ }
+ .navigationTitle(sub?.name ?? "Подписка")
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear { store.markSeen(id) }
+ }
+
+ private func content(_ sub: SavedSubscription) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ nameSection(sub)
+ statusSection(sub)
+
+ PrimaryButton(title: "Обновить", isLoading: coordinator.isRefreshing) {
+ Task { await coordinator.refreshAll() }
+ }
+ .disabled(coordinator.isRefreshing)
+
+ if let source = sub.source, !source.isEmpty {
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel("Источник подписки")
+ SourceCard(source: source)
+ }
+ }
+
+ if let error = sub.lastError {
+ GroupedCard {
+ Label(error, systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .font(.callout)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(14)
+ }
+ }
+
+ if !sub.lastConfigs.isEmpty {
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel(sub.mode.map { "Конфиги · \($0)" } ?? "Конфиги") {
+ ConfigsHeaderActions(mode: sub.mode ?? "", uris: sub.lastConfigs)
+ }
+ ConfigListCard(uris: sub.lastConfigs)
+ }
+ }
+
+ deleteButton
+ }
+ .padding(Layout.screenPadding)
+ }
+ .background(Color(.systemGroupedBackground))
+ }
+
+ private func nameSection(_ sub: SavedSubscription) -> some View {
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel("Название")
+ GroupedCard {
+ TextField("Название", text: nameBinding(sub))
+ .autocorrectionDisabled()
+ .padding(14)
+ }
+ }
+ }
+
+ private func statusSection(_ sub: SavedSubscription) -> some View {
+ VStack(alignment: .leading, spacing: Layout.rowSpacing) {
+ SectionLabel("Состояние")
+ GroupedCard {
+ infoRow("Проверено", RelativeTime.string(sub.lastCheckedAt))
+ Divider().padding(.leading, 14)
+ infoRow("Изменено", RelativeTime.string(sub.lastChangedAt))
+ Divider().padding(.leading, 14)
+ Toggle(isOn: notifyBinding(sub)) {
+ Text("Уведомлять об обновлениях")
+ }
+ .padding(14)
+ }
+ }
+ }
+
+ private func infoRow(_ title: String, _ value: String) -> some View {
+ HStack {
+ Text(title)
+ Spacer()
+ Text(value).foregroundStyle(.secondary)
+ }
+ .padding(14)
+ }
+
+ private var deleteButton: some View {
+ Button(role: .destructive) {
+ store.remove(id)
+ dismiss()
+ } label: {
+ Label("Удалить подписку", systemImage: "trash")
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 13)
+ }
+ .buttonStyle(.bordered)
+ .tint(.red)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ }
+
+ private func nameBinding(_ sub: SavedSubscription) -> Binding {
+ Binding(
+ get: { store.items.first { $0.id == id }?.name ?? sub.name },
+ set: { newValue in
+ var s = sub
+ s.name = newValue
+ store.update(s)
+ }
+ )
+ }
+
+ private func notifyBinding(_ sub: SavedSubscription) -> Binding {
+ Binding(
+ get: { store.items.first { $0.id == id }?.notify ?? sub.notify },
+ set: { newValue in
+ var s = sub
+ s.notify = newValue
+ store.update(s)
+ }
+ )
+ }
+}
diff --git a/ios/happwn/UI/SubscriptionsListView.swift b/ios/happwn/UI/SubscriptionsListView.swift
new file mode 100644
index 0000000..f6f1716
--- /dev/null
+++ b/ios/happwn/UI/SubscriptionsListView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+struct SubscriptionsListView: View {
+ @EnvironmentObject private var store: SubscriptionStore
+ @EnvironmentObject private var coordinator: RefreshCoordinator
+ @State private var showingAdd = false
+
+ var body: some View {
+ Group {
+ if store.items.isEmpty {
+ emptyState
+ } else {
+ list
+ }
+ }
+ .navigationTitle("Подписки")
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button { showingAdd = true } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showingAdd) { AddSubscriptionView() }
+ }
+
+ private var list: some View {
+ List {
+ ForEach(store.items) { sub in
+ NavigationLink {
+ SubscriptionDetailView(id: sub.id)
+ } label: {
+ row(sub)
+ }
+ }
+ .onDelete { store.remove(atOffsets: $0) }
+ }
+ .refreshable { await coordinator.refreshAll() }
+ }
+
+ private func row(_ sub: SavedSubscription) -> some View {
+ HStack(spacing: 12) {
+ Circle()
+ .fill(sub.hasUnseenUpdate ? Color.accentColor : .clear)
+ .frame(width: 8, height: 8)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(sub.name).font(.body.weight(.semibold))
+ Text("\(sub.configCount) конфигов · обновлено \(RelativeTime.string(sub.lastCheckedAt))")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ if sub.lastError != nil {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .font(.footnote)
+ }
+ }
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 14) {
+ Image(systemName: "square.stack.3d.up")
+ .font(.system(size: 48, weight: .light))
+ .foregroundStyle(.tertiary)
+ Text("Нет сохранённых подписок")
+ .font(.headline)
+ Text("Добавь happ://-ссылку, чтобы следить за обновлениями конфигов.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ Button {
+ showingAdd = true
+ } label: {
+ Label("Добавить подписку", systemImage: "plus")
+ }
+ .buttonStyle(.borderedProminent)
+ .padding(.top, 4)
+ }
+ .padding(40)
+ }
+}
+
+/// Shared relative-time formatting for "обновлено N назад".
+enum RelativeTime {
+ private static let formatter: RelativeDateTimeFormatter = {
+ let f = RelativeDateTimeFormatter()
+ f.locale = Locale(identifier: "ru_RU")
+ f.unitsStyle = .full
+ return f
+ }()
+
+ static func string(_ date: Date?) -> String {
+ guard let date else { return "никогда" }
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+}
diff --git a/ios/happwn/happwnApp.swift b/ios/happwn/happwnApp.swift
index 3efb35a..47deb06 100644
--- a/ios/happwn/happwnApp.swift
+++ b/ios/happwn/happwnApp.swift
@@ -2,14 +2,53 @@ import SwiftUI
@main
struct HappwnApp: App {
- @StateObject private var settings = Settings()
+ @StateObject private var settings: Settings
+ @StateObject private var store: SubscriptionStore
+ @StateObject private var coordinator: RefreshCoordinator
+ @Environment(\.scenePhase) private var scenePhase
+
+ init() {
+ let settings = Settings()
+ let store = SubscriptionStore()
+ let coordinator = RefreshCoordinator(store: store, settings: settings)
+ _settings = StateObject(wrappedValue: settings)
+ _store = StateObject(wrappedValue: store)
+ _coordinator = StateObject(wrappedValue: coordinator)
+
+ BackgroundRefresh.register(
+ coordinator: { coordinator },
+ minInterval: { settings.minRefreshInterval.seconds }
+ )
+ }
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(settings)
+ .environmentObject(store)
+ .environmentObject(coordinator)
.tint(settings.accent.color)
.preferredColorScheme(settings.appearance.colorScheme)
+ .task {
+ if settings.notificationsEnabled {
+ await NotificationService().requestAuthorization()
+ }
+ if settings.backgroundRefreshEnabled {
+ BackgroundRefresh.schedule(minInterval: settings.minRefreshInterval.seconds)
+ }
+ }
+ .onChange(of: scenePhase) { phase in
+ switch phase {
+ case .active:
+ Task { await coordinator.refreshAll() }
+ case .background:
+ if settings.backgroundRefreshEnabled {
+ BackgroundRefresh.schedule(minInterval: settings.minRefreshInterval.seconds)
+ }
+ default:
+ break
+ }
+ }
}
}
}
diff --git a/ios/happwnTests/ChangeDetectorTests.swift b/ios/happwnTests/ChangeDetectorTests.swift
new file mode 100644
index 0000000..130209d
--- /dev/null
+++ b/ios/happwnTests/ChangeDetectorTests.swift
@@ -0,0 +1,31 @@
+import XCTest
+@testable import happwn
+
+final class ChangeDetectorTests: XCTestCase {
+ func testNoChangeIgnoresOrder() {
+ let d = ChangeDetector.diff(old: ["a", "b"], new: ["b", "a"])
+ XCTAssertFalse(d.changed)
+ XCTAssertEqual(d.added, 0)
+ XCTAssertEqual(d.removed, 0)
+ }
+
+ func testAddedAndRemoved() {
+ let d = ChangeDetector.diff(old: ["a", "b"], new: ["b", "c", "d"])
+ XCTAssertEqual(d.added, 2) // c, d
+ XCTAssertEqual(d.removed, 1) // a
+ XCTAssertTrue(d.changed)
+ }
+
+ func testFromEmpty() {
+ let d = ChangeDetector.diff(old: [], new: ["a"])
+ XCTAssertEqual(d.added, 1)
+ XCTAssertEqual(d.removed, 0)
+ XCTAssertTrue(d.changed)
+ }
+
+ func testToEmpty() {
+ let d = ChangeDetector.diff(old: ["a", "b"], new: [])
+ XCTAssertEqual(d.removed, 2)
+ XCTAssertTrue(d.changed)
+ }
+}
diff --git a/ios/happwnTests/PlainURLExtractionTests.swift b/ios/happwnTests/PlainURLExtractionTests.swift
new file mode 100644
index 0000000..c58af47
--- /dev/null
+++ b/ios/happwnTests/PlainURLExtractionTests.swift
@@ -0,0 +1,33 @@
+import XCTest
+@testable import happwn
+
+private struct StubFetcher: HTTPFetching {
+ var handler: (URLRequest) throws -> (Data, URLResponse)
+ func data(for request: URLRequest) async throws -> (Data, URLResponse) {
+ try handler(request)
+ }
+}
+
+/// A plain http(s) subscription URL should skip decryption, fetch directly,
+/// and parse configs.
+final class PlainURLExtractionTests: XCTestCase {
+ func testPlainURLSkipsDecryptionAndFetches() async throws {
+ let body = "vless://uuid@host:443#A\nvmess://x"
+ let service = ExtractionService(
+ decryptLink: { _ in
+ XCTFail("plain URL must not be decrypted")
+ throw SubscriptionError.empty
+ },
+ client: SubscriptionClient(session: StubFetcher { req in
+ XCTAssertEqual(req.url?.absoluteString, "https://sub.example/list")
+ return (Data(body.utf8),
+ HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
+ })
+ )
+ let result = try await service.run(link: "https://sub.example/list", userAgent: "u", hwid: "h")
+ XCTAssertEqual(result.mode, "url")
+ XCTAssertEqual(result.source, "https://sub.example/list")
+ XCTAssertEqual(result.configs.count, 2)
+ XCTAssertEqual(result.configs.first?.scheme, "vless")
+ }
+}
diff --git a/ios/happwnTests/RefreshServiceTests.swift b/ios/happwnTests/RefreshServiceTests.swift
new file mode 100644
index 0000000..d322b62
--- /dev/null
+++ b/ios/happwnTests/RefreshServiceTests.swift
@@ -0,0 +1,79 @@
+import XCTest
+@testable import happwn
+
+private final class SpyNotifier: SubscriptionNotifying, @unchecked Sendable {
+ private(set) var calls: [(name: String, added: Int, removed: Int)] = []
+ func notifyChange(subscription: SavedSubscription, added: Int, removed: Int) async {
+ calls.append((subscription.name, added, removed))
+ }
+}
+
+final class RefreshServiceTests: XCTestCase {
+ private func result(_ uris: [String]) -> ExtractionResult {
+ ExtractionResult(mode: "url", source: "https://s",
+ configs: uris.map { ConfigEntry(uri: $0) }, rawBody: nil)
+ }
+
+ func testDetectsChangeAndNotifies() async {
+ var sub = SavedSubscription(name: "S", link: "https://s")
+ sub.lastConfigs = ["a"]
+ let spy = SpyNotifier()
+ var service = RefreshService()
+ service.extract = { _, _, _ in self.result(["a", "b"]) }
+
+ let updated = await service.refreshAll([sub], userAgent: "u", hwid: "h",
+ notificationsEnabled: true, notifier: spy)
+
+ XCTAssertEqual(updated.first?.lastConfigs.count, 2)
+ XCTAssertEqual(updated.first?.hasUnseenUpdate, true)
+ XCTAssertNotNil(updated.first?.lastChangedAt)
+ XCTAssertEqual(spy.calls.count, 1)
+ XCTAssertEqual(spy.calls.first?.added, 1)
+ XCTAssertEqual(spy.calls.first?.removed, 0)
+ }
+
+ func testNoChangeNoNotify() async {
+ var sub = SavedSubscription(name: "S", link: "https://s")
+ sub.lastConfigs = ["a"]
+ let spy = SpyNotifier()
+ var service = RefreshService()
+ service.extract = { _, _, _ in self.result(["a"]) }
+
+ let updated = await service.refreshAll([sub], userAgent: "u", hwid: "h",
+ notificationsEnabled: true, notifier: spy)
+
+ XCTAssertEqual(updated.first?.hasUnseenUpdate, false)
+ XCTAssertTrue(spy.calls.isEmpty)
+ }
+
+ func testNotificationsDisabledStillFlagsButDoesNotNotify() async {
+ let sub = SavedSubscription(name: "S", link: "https://s") // empty lastConfigs
+ let spy = SpyNotifier()
+ var service = RefreshService()
+ service.extract = { _, _, _ in self.result(["a"]) }
+
+ let updated = await service.refreshAll([sub], userAgent: "u", hwid: "h",
+ notificationsEnabled: false, notifier: spy)
+
+ XCTAssertEqual(updated.first?.hasUnseenUpdate, true)
+ XCTAssertTrue(spy.calls.isEmpty)
+ }
+
+ func testFailingSubscriptionDoesNotAbortOthers() async {
+ let bad = SavedSubscription(name: "bad", link: "https://b")
+ let good = SavedSubscription(name: "good", link: "https://g")
+ let spy = SpyNotifier()
+ var service = RefreshService()
+ service.extract = { link, _, _ in
+ if link == "https://b" { throw SubscriptionError.empty }
+ return self.result(["a"])
+ }
+
+ let updated = await service.refreshAll([bad, good], userAgent: "u", hwid: "h",
+ notificationsEnabled: true, notifier: spy)
+
+ XCTAssertEqual(updated.count, 2)
+ XCTAssertNotNil(updated.first { $0.name == "bad" }?.lastError)
+ XCTAssertNil(updated.first { $0.name == "good" }?.lastError)
+ }
+}
diff --git a/ios/happwnTests/SubscriptionStoreTests.swift b/ios/happwnTests/SubscriptionStoreTests.swift
new file mode 100644
index 0000000..701ac80
--- /dev/null
+++ b/ios/happwnTests/SubscriptionStoreTests.swift
@@ -0,0 +1,56 @@
+import XCTest
+@testable import happwn
+
+@MainActor
+final class SubscriptionStoreTests: XCTestCase {
+ private func tempURL() -> URL {
+ FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".json")
+ }
+
+ func testAddPersistsAndReloads() {
+ let url = tempURL()
+ let store = SubscriptionStore(fileURL: url)
+ store.add(SavedSubscription(name: "X", link: "happ://x"))
+ XCTAssertEqual(store.items.count, 1)
+
+ let reloaded = SubscriptionStore(fileURL: url)
+ XCTAssertEqual(reloaded.items.count, 1)
+ XCTAssertEqual(reloaded.items.first?.name, "X")
+ XCTAssertEqual(reloaded.items.first?.link, "happ://x")
+ }
+
+ func testRemoveByID() {
+ let store = SubscriptionStore(fileURL: tempURL())
+ let sub = SavedSubscription(name: "X", link: "happ://x")
+ store.add(sub)
+ store.remove(sub.id)
+ XCTAssertTrue(store.items.isEmpty)
+ }
+
+ func testUpdate() {
+ let store = SubscriptionStore(fileURL: tempURL())
+ var sub = SavedSubscription(name: "X", link: "happ://x")
+ store.add(sub)
+ sub.name = "Y"
+ store.update(sub)
+ XCTAssertEqual(store.items.first?.name, "Y")
+ }
+
+ func testMarkSeenClearsBadge() {
+ let store = SubscriptionStore(fileURL: tempURL())
+ var sub = SavedSubscription(name: "X", link: "happ://x")
+ sub.hasUnseenUpdate = true
+ store.add(sub)
+ store.markSeen(sub.id)
+ XCTAssertEqual(store.items.first?.hasUnseenUpdate, false)
+ }
+
+ func testReplaceAll() {
+ let store = SubscriptionStore(fileURL: tempURL())
+ store.add(SavedSubscription(name: "A", link: "happ://a"))
+ var updated = store.items
+ updated[0].lastConfigs = ["vless://x"]
+ store.replaceAll(updated)
+ XCTAssertEqual(store.items.first?.lastConfigs, ["vless://x"])
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index e5b983c..33eacb9 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -8,8 +8,8 @@ settings:
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_CFBundleDisplayName: happwn
- MARKETING_VERSION: "1.0.1"
- CURRENT_PROJECT_VERSION: "2"
+ MARKETING_VERSION: "1.0.2"
+ CURRENT_PROJECT_VERSION: "3"
SWIFT_VERSION: "5.0"
TARGETED_DEVICE_FAMILY: "1,2"
targets:
@@ -18,10 +18,14 @@ targets:
platform: iOS
sources:
- path: happwn
+ excludes:
+ - "Info.plist"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.happwn.app
SWIFT_OBJC_BRIDGING_HEADER: happwn/Happwn-Bridging-Header.h
+ GENERATE_INFOPLIST_FILE: NO
+ INFOPLIST_FILE: happwn/Info.plist
dependencies:
- framework: HappwnCrypto.xcframework
embed: false