diff --git a/ios/happwn/Core/BackgroundRefresh.swift b/ios/happwn/Core/BackgroundRefresh.swift new file mode 100644 index 0000000..88df484 --- /dev/null +++ b/ios/happwn/Core/BackgroundRefresh.swift @@ -0,0 +1,44 @@ +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. Like AltStore, a sideloaded app can +/// use BGAppRefreshTask + UIBackgroundModes(fetch) and still install fine. +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, even if this run fails. + 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/Info.plist b/ios/happwn/Info.plist new file mode 100644 index 0000000..df8b1fe --- /dev/null +++ b/ios/happwn/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + 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 + + UIBackgroundModes + + fetch + + BGTaskSchedulerPermittedIdentifiers + + com.happwn.refresh + + + diff --git a/ios/happwn/Models/Models.swift b/ios/happwn/Models/Models.swift index 3d57fd1..5e9b7b9 100644 --- a/ios/happwn/Models/Models.swift +++ b/ios/happwn/Models/Models.swift @@ -8,6 +8,45 @@ struct ConfigEntry: Identifiable, Equatable { String(uri.prefix(while: { $0 != ":" })).lowercased() } + /// Server address (IP or domain) extracted from the config, when determinable. + /// Handles the `scheme://[userinfo@]host:port` form (vless, trojan, ss…) and + /// the base64-JSON form (vmess, "add" field). + var host: String? { + if scheme == "vmess" { return vmessHost() } + return authorityHost() + } + + private func vmessHost() -> String? { + let payload = String(uri.dropFirst("vmess://".count)) + guard let data = Self.base64Decode(payload), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let add = obj["add"] as? String, !add.isEmpty else { return nil } + return add + } + + private func authorityHost() -> String? { + guard let r = uri.range(of: "://") else { return nil } + var authority = uri[r.upperBound...] + if let cut = authority.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) { + authority = authority[.. Data? { + var t = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while t.count % 4 != 0 { t.append("=") } + return Data(base64Encoded: t) + } + static func == (lhs: ConfigEntry, rhs: ConfigEntry) -> Bool { lhs.uri == rhs.uri } diff --git a/ios/happwn/Store/Settings.swift b/ios/happwn/Store/Settings.swift index 386e627..fb4dd2d 100644 --- a/ios/happwn/Store/Settings.swift +++ b/ios/happwn/Store/Settings.swift @@ -1,6 +1,16 @@ import Foundation import Combine +/// Minimum spacing the OS waits between background refresh 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 { @@ -18,6 +28,12 @@ final class Settings: ObservableObject { @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 @@ -27,6 +43,8 @@ final class Settings: ObservableObject { 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) { @@ -37,5 +55,8 @@ final class Settings: ObservableObject { 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/UI/ConfigComponents.swift b/ios/happwn/UI/ConfigComponents.swift index 0dc55e3..e297247 100644 --- a/ios/happwn/UI/ConfigComponents.swift +++ b/ios/happwn/UI/ConfigComponents.swift @@ -51,20 +51,14 @@ struct ConfigListCard: View { } private func row(index: Int, uri: String) -> some View { - let scheme = ConfigEntry(uri: uri).scheme + let entry = ConfigEntry(uri: uri) 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 } - } + copy(uri, at: index) } label: { HStack(spacing: 12) { IconBadge(systemName: "", - color: ProtocolStyle.color(for: scheme), - text: ProtocolStyle.badge(for: scheme)) + color: ProtocolStyle.color(for: entry.scheme), + text: ProtocolStyle.badge(for: entry.scheme)) Text(uri) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.primary) @@ -78,6 +72,31 @@ struct ConfigListCard: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .contextMenu { + Button { + copy(uri, at: index) + } label: { + Label("Скопировать конфиг", systemImage: "doc.on.doc") + } + if let host = entry.host { + Button { + UIPasteboard.general.string = host + Haptics.tap() + } label: { + Label("Скопировать IP · \(host)", systemImage: "network") + } + } + } + } + + private func copy(_ value: String, at index: Int) { + UIPasteboard.general.string = value + Haptics.tap() + copiedIndex = index + Task { + try? await Task.sleep(nanoseconds: 1_400_000_000) + if copiedIndex == index { copiedIndex = nil } + } } } diff --git a/ios/happwn/UI/SettingsView.swift b/ios/happwn/UI/SettingsView.swift index c6c5abd..6260c3d 100644 --- a/ios/happwn/UI/SettingsView.swift +++ b/ios/happwn/UI/SettingsView.swift @@ -66,10 +66,25 @@ struct SettingsView: View { systemImage: "bell.badge") } } + Toggle("Фоновое обновление", isOn: $settings.backgroundRefreshEnabled) + .onChange(of: settings.backgroundRefreshEnabled) { enabled in + if enabled { + BackgroundRefresh.schedule(minInterval: settings.minRefreshInterval.seconds) + } else { + BackgroundRefresh.cancel() + } + } + if settings.backgroundRefreshEnabled { + Picker("Проверять не чаще чем", selection: $settings.minRefreshInterval) { + ForEach(RefreshInterval.allCases) { interval in + Text(interval.label).tag(interval) + } + } + } } header: { Text("Обновления") } footer: { - Text("Подписки обновляются при открытии приложения и по pull-to-refresh. Если набор конфигов изменился — придёт уведомление.") + Text("Подписки обновляются при открытии, по pull-to-refresh и в фоне. Фоновую проверку iOS запускает по своему усмотрению (примерно раз в несколько часов, точный интервал не гарантирован). При изменении конфигов придёт уведомление.") } Section { diff --git a/ios/happwn/happwnApp.swift b/ios/happwn/happwnApp.swift index 7913c4a..252b5d3 100644 --- a/ios/happwn/happwnApp.swift +++ b/ios/happwn/happwnApp.swift @@ -10,9 +10,16 @@ struct HappwnApp: App { 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: RefreshCoordinator(store: store, settings: settings)) + _coordinator = StateObject(wrappedValue: coordinator) + + // Must register before the app finishes launching. + BackgroundRefresh.register( + coordinator: { coordinator }, + minInterval: { settings.minRefreshInterval.seconds } + ) } var body: some Scene { @@ -28,10 +35,20 @@ struct HappwnApp: App { if settings.notificationsEnabled { await NotificationService().requestAuthorization() } + if settings.backgroundRefreshEnabled { + BackgroundRefresh.schedule(minInterval: settings.minRefreshInterval.seconds) + } } .onChange(of: scenePhase) { phase in - if phase == .active { + 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/ConfigHostTests.swift b/ios/happwnTests/ConfigHostTests.swift new file mode 100644 index 0000000..1e93926 --- /dev/null +++ b/ios/happwnTests/ConfigHostTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import happwn + +final class ConfigHostTests: XCTestCase { + func testVlessIP() { + XCTAssertEqual(ConfigEntry(uri: "vless://uuid@1.2.3.4:443?type=tcp&security=reality#name").host, "1.2.3.4") + } + + func testTrojanDomain() { + XCTAssertEqual(ConfigEntry(uri: "trojan://pass@example.com:8443?sni=a.b#tag").host, "example.com") + } + + func testSSWithUserinfo() { + XCTAssertEqual(ConfigEntry(uri: "ss://YWVzOnB3@9.9.9.9:8388#node").host, "9.9.9.9") + } + + func testIPv6Literal() { + XCTAssertEqual(ConfigEntry(uri: "vless://uuid@[2001:db8::1]:443#y").host, "2001:db8::1") + } + + func testVmessBase64() { + let json = "{\"add\":\"5.6.7.8\",\"port\":\"443\",\"id\":\"uuid\"}" + let b64 = Data(json.utf8).base64EncodedString() + XCTAssertEqual(ConfigEntry(uri: "vmess://\(b64)").host, "5.6.7.8") + } + + func testNoHost() { + XCTAssertNil(ConfigEntry(uri: "not-a-uri").host) + } +} diff --git a/ios/project.yml b/ios/project.yml index 778a1b9..a3fa58f 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.4" - CURRENT_PROJECT_VERSION: "5" + MARKETING_VERSION: "1.0.5" + CURRENT_PROJECT_VERSION: "6" 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