From b50cd6ef4a943ab8112707483527a8ecf7bc78d2 Mon Sep 17 00:00:00 2001
From: useruserdev <256019073+useruserdev@users.noreply.github.com>
Date: Thu, 4 Jun 2026 06:14:32 +0500
Subject: [PATCH 1/2] Re-add background refresh with a correct Info.plist
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Brings back opportunistic background refresh (BGAppRefreshTask) so block/config changes can notify without opening the app — the modern equivalent of what AltStore (also sideloaded) does.
Key difference from the earlier attempt that broke install: the Info.plist is now a faithful superset of the working generated plist (adds CFBundleDevelopmentRegion that was missing) plus only the two background keys (UIBackgroundModes=[fetch], BGTaskSchedulerPermittedIdentifiers=[com.happwn.refresh]). Background fetch needs no entitlement, so it installs on a paid cert.
- Restore BackgroundRefresh (register/schedule/cancel), happwnApp wiring, Settings background toggle + interval (default on).
- Refresh now runs in background + on launch + pull-to-refresh.
- Bump to 1.0.5 (build 6).
---
ios/happwn/Core/BackgroundRefresh.swift | 44 +++++++++++++++++++++++++
ios/happwn/Info.plist | 36 ++++++++++++++++++++
ios/happwn/Store/Settings.swift | 21 ++++++++++++
ios/happwn/UI/SettingsView.swift | 17 +++++++++-
ios/happwn/happwnApp.swift | 21 ++++++++++--
ios/project.yml | 8 +++--
6 files changed, 142 insertions(+), 5 deletions(-)
create mode 100644 ios/happwn/Core/BackgroundRefresh.swift
create mode 100644 ios/happwn/Info.plist
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/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/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/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
From 8c6afacffbb22f6f47eac16238a969d2729468c1 Mon Sep 17 00:00:00 2001
From: useruserdev <256019073+useruserdev@users.noreply.github.com>
Date: Thu, 4 Jun 2026 06:23:20 +0500
Subject: [PATCH 2/2] Add copy-server-IP from config via context menu
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Long-press a config row to copy just the server address (host/IP) separately from the full URI. ConfigEntry.host parses the scheme://[userinfo@]host:port form (vless, trojan, ss…) and the vmess base64-JSON 'add' field; handles IPv6 literals. Tap still copies the full config. Tests cover vless/trojan/ss/vmess/IPv6.
---
ios/happwn/Models/Models.swift | 39 +++++++++++++++++++++++++++
ios/happwn/UI/ConfigComponents.swift | 39 ++++++++++++++++++++-------
ios/happwnTests/ConfigHostTests.swift | 30 +++++++++++++++++++++
3 files changed, 98 insertions(+), 10 deletions(-)
create mode 100644 ios/happwnTests/ConfigHostTests.swift
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/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/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)
+ }
+}