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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions ios/happwn/Core/BackgroundRefresh.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
36 changes: 36 additions & 0 deletions ios/happwn/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>happwn</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.happwn.refresh</string>
</array>
</dict>
</plist>
39 changes: 39 additions & 0 deletions ios/happwn/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<cut]
}
if let at = authority.lastIndex(of: "@") {
authority = authority[authority.index(after: at)...]
}
let s = String(authority)
if s.hasPrefix("["), let close = s.firstIndex(of: "]") { // IPv6 literal
return String(s[s.index(after: s.startIndex)..<close])
}
let h = s.prefix { $0 != ":" }
return h.isEmpty ? nil : String(h)
}

private static func base64Decode(_ s: String) -> 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
}
Expand Down
21 changes: 21 additions & 0 deletions ios/happwn/Store/Settings.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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

Expand All @@ -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) {
Expand All @@ -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
}
}
39 changes: 29 additions & 10 deletions ios/happwn/UI/ConfigComponents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 }
}
}
}

Expand Down
17 changes: 16 additions & 1 deletion ios/happwn/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 19 additions & 2 deletions ios/happwn/happwnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions ios/happwnTests/ConfigHostTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 6 additions & 2 deletions ios/project.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading