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