Files
sing-box-for-apple/ApplicationLibrary/Views/Setting/OnDemandRulesView.swift
T

865 lines
28 KiB
Swift

import Foundation
import Library
import SwiftUI
private enum OnDemandMode: String, CaseIterable, Identifiable {
case disabled
case alwaysOn
case enabled
var id: String {
rawValue
}
var name: String {
switch self {
case .disabled: String(localized: "Disabled")
case .alwaysOn: String(localized: "Always On")
case .enabled: String(localized: "Enabled")
}
}
var description: String {
switch self {
case .disabled: String(localized: "VPN will not connect automatically.")
case .alwaysOn: String(localized: "Automatically connect VPN on any network.")
case .enabled: String(localized: "Automatically connect or disconnect VPN based on rules.")
}
}
}
public struct OnDemandRulesView: View {
@EnvironmentObject private var environments: ExtensionEnvironments
@State private var isLoading = true
@State private var alert: AlertState?
@State private var mode: OnDemandMode = .disabled
@State private var rules: [OnDemandRule] = []
@State private var editingRule: OnDemandRule?
@State private var isAddingRule = false
@State private var loadTask: Task<Void, Never>?
#if os(iOS)
@State private var editMode: EditMode = .inactive
#endif
public init() {}
public var body: some View {
Group {
if isLoading {
ProgressView().onAppear {
loadTask = Task {
await loadSettings()
}
}
} else {
FormView {
modePicker
if mode == .enabled {
rulesSection
}
resetButton
}
}
}
.navigationTitle("On Demand Rules")
.onDisappear {
loadTask?.cancel()
}
.alert($alert)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
.disabled(rules.isEmpty)
}
}
.environment(\.editMode, $editMode)
#endif
#if !os(tvOS)
.platformSheet(isPresented: $isAddingRule) {
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
rules.append(newRule)
Task {
await saveRules()
}
}
}
.platformSheet(item: $editingRule) { rule in
OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in
if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) {
rules[index] = updatedRule
Task {
await saveRules()
}
}
}
}
#endif
}
private var modePicker: some View {
Section {
Picker("Mode", selection: $mode) {
ForEach(OnDemandMode.allCases) { m in
Text(m.name).tag(m)
}
}
.onChange(of: mode) { newValue in
Task {
await saveMode(newValue)
}
}
} footer: {
Text(mode.description)
}
}
private var rulesSection: some View {
Section {
if rules.isEmpty {
Text("Empty rules")
.foregroundStyle(.secondary)
.font(.callout)
} else {
ForEach(rules) { rule in
ruleRow(rule)
}
.onMove { from, to in
rules.move(fromOffsets: from, toOffset: to)
Task {
await saveRules()
}
}
.onDelete { offsets in
rules.remove(atOffsets: offsets)
Task {
await saveRules()
}
}
}
} header: {
HStack {
Text("Rules")
Spacer()
#if os(tvOS)
FormNavigationLink {
OnDemandRuleEditView(rule: OnDemandRule(), isNew: true) { newRule in
rules.append(newRule)
Task {
await saveRules()
}
}
} label: {
Image(systemName: "plus.circle.fill")
}
#else
Button {
isAddingRule = true
} label: {
Image(systemName: "plus.circle.fill")
}
#if os(macOS)
.buttonStyle(.plain)
#endif
#endif
}
} footer: {
Text("Rules are evaluated in order from top to bottom. The first matching rule determines the action.")
}
}
@ViewBuilder
private func ruleRow(_ rule: OnDemandRule) -> some View {
#if os(tvOS)
FormNavigationLink {
OnDemandRuleEditView(rule: rule, isNew: false) { updatedRule in
if let index = rules.firstIndex(where: { $0.id == updatedRule.id }) {
rules[index] = updatedRule
Task {
await saveRules()
}
}
}
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(rule.action.name)
.fontWeight(.medium)
Text(ruleDescription(rule))
.font(.caption)
.foregroundStyle(.secondary)
}
}
#else
Button {
editingRule = rule
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(rule.action.name)
.fontWeight(.medium)
Text(ruleDescription(rule))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
}
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.plain)
#elseif os(iOS)
.foregroundStyle(.primary)
#endif
#endif
}
private func ruleDescription(_ rule: OnDemandRule) -> String {
var parts: [String] = []
if rule.interfaceType != .any {
parts.append(rule.interfaceType.name)
}
if !rule.ssidMatch.isEmpty {
let ssids = rule.ssidMatch.prefix(2).joined(separator: ", ")
if rule.ssidMatch.count > 2 {
parts.append("SSID: \(ssids) +\(rule.ssidMatch.count - 2)")
} else {
parts.append("SSID: \(ssids)")
}
}
if !rule.dnsSearchDomainMatch.isEmpty {
parts.append("DNS Domain")
}
if !rule.dnsServerAddressMatch.isEmpty {
parts.append("DNS Server")
}
if !rule.probeURL.isEmpty {
parts.append("Probe URL")
}
if rule.action == .evaluateConnection, !rule.connectionRules.isEmpty {
parts.append("\(rule.connectionRules.count) connection rule(s)")
}
if parts.isEmpty {
return "All networks"
}
return parts.joined(separator: " · ")
}
private var resetButton: some View {
FormButton {
Task {
do {
try await SharedPreferences.resetOnDemandRules()
await updateService()
isLoading = true
} catch {
alert = AlertState(action: "reset on-demand rules", error: error)
}
}
} label: {
Label("Reset", systemImage: "eraser.fill")
}
.foregroundStyle(.red)
}
private func saveMode(_ newMode: OnDemandMode) async {
let alwaysOn = newMode == .alwaysOn
let onDemandEnabled = newMode == .enabled
await SharedPreferences.alwaysOn.set(alwaysOn)
await SharedPreferences.onDemandEnabled.set(onDemandEnabled)
await updateService()
}
private func updateService() async {
guard let profile = environments.extensionProfile, profile.status.isConnected else {
return
}
do {
let enabled = mode != .disabled
try await profile.updateOnDemand(enabled: enabled, useDefaultRules: mode == .alwaysOn)
} catch {
alert = AlertState(action: "update on-demand rules", error: error)
}
}
private func saveRules() async {
await SharedPreferences.onDemandRules.set(rules)
let savedRules = await SharedPreferences.onDemandRules.get()
if savedRules != rules {
alert = AlertState(errorMessage: String(localized: "Failed to save rules"))
return
}
await updateService()
}
private func loadSettings() async {
let alwaysOn = await SharedPreferences.alwaysOn.get()
let onDemandEnabled = await SharedPreferences.onDemandEnabled.get()
if alwaysOn {
mode = .alwaysOn
} else if onDemandEnabled {
mode = .enabled
} else {
mode = .disabled
}
rules = await SharedPreferences.onDemandRules.get()
isLoading = false
}
}
private struct OnDemandRuleEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var rule: OnDemandRule
private let onSave: (OnDemandRule) -> Void
private let isNew: Bool
@State private var editingConnectionRule: EvaluateConnectionRule?
@State private var isAddingConnectionRule = false
private var isProbeURLValid: Bool {
guard !rule.probeURL.isEmpty else { return true }
guard let url = URL(string: rule.probeURL),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else { return false }
return true
}
init(rule: OnDemandRule, isNew: Bool, onSave: @escaping (OnDemandRule) -> Void) {
_rule = State(initialValue: rule)
self.onSave = onSave
self.isNew = isNew
}
var body: some View {
Form {
actionSection
conditionsSection
if rule.action == .evaluateConnection {
connectionRulesSection
}
#if os(tvOS)
Section {
Button(isNew ? "Create" : "Save") {
onSave(rule)
dismiss()
}
.disabled(!isProbeURLValid)
}
#endif
}
.navigationTitle(isNew ? "New Rule" : "Edit Rule")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if !os(tvOS)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isNew ? "Create" : "Save") {
onSave(rule)
dismiss()
}
.disabled(!isProbeURLValid)
}
}
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
.platformSheet(isPresented: $isAddingConnectionRule, size: .small) {
EvaluateConnectionRuleEditView(rule: EvaluateConnectionRule()) { newRule in
rule.connectionRules.append(newRule)
}
}
.platformSheet(item: $editingConnectionRule, size: .small) { connRule in
EvaluateConnectionRuleEditView(rule: connRule) { updatedRule in
if let index = rule.connectionRules.firstIndex(where: { $0.id == updatedRule.id }) {
rule.connectionRules[index] = updatedRule
}
}
}
}
private var actionSection: some View {
Section {
Picker("Action", selection: $rule.action) {
ForEach(OnDemandRuleAction.allCases) { action in
Text(action.name).tag(action)
}
}
#if os(iOS)
.pickerStyle(.menu)
#endif
Picker("Interface Type", selection: $rule.interfaceType) {
ForEach(OnDemandRuleInterfaceType.availableCases, id: \.self) { type in
Text(type.name).tag(type)
}
}
#if os(iOS)
.pickerStyle(.menu)
#endif
} header: {
Text("Action")
} footer: {
Text(rule.action.actionDescription)
}
}
private var conditionsSection: some View {
Section {
StringListSection(title: "SSID Match", placeholder: "Add SSID", items: $rule.ssidMatch)
StringListSection(title: "DNS Search Domain", placeholder: "Add domain", items: $rule.dnsSearchDomainMatch)
StringListSection(title: "DNS Server Address", placeholder: "Add DNS server IP", items: $rule.dnsServerAddressMatch)
probeURLSection
} header: {
Text("Conditions")
} footer: {
Text("All specified conditions must match for the rule to apply. Leave empty to match any network.")
}
}
@ViewBuilder
private var probeURLSection: some View {
#if !os(tvOS)
VStack(alignment: .leading) {
HStack {
Text("Probe URL")
Spacer()
TextField("http://...", text: $rule.probeURL)
.multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
}
if !isProbeURLValid {
Text("Only HTTP and HTTPS URLs are allowed")
.font(.caption)
.foregroundStyle(.red)
}
}
#else
HStack {
Text("Probe URL")
Spacer()
Text(rule.probeURL.isEmpty ? "Not set" : rule.probeURL)
.foregroundStyle(.secondary)
}
#endif
}
private var connectionRulesSection: some View {
Section {
if rule.connectionRules.isEmpty {
Text("No connection rules. Add rules to specify which domains trigger VPN connection.")
.foregroundStyle(.secondary)
.font(.callout)
} else {
ForEach(rule.connectionRules) { connRule in
Button {
editingConnectionRule = connRule
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(connRule.action.name)
.fontWeight(.medium)
if !connRule.matchDomains.isEmpty {
Text(connRule.matchDomains.prefix(3).joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
#if !os(tvOS)
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
.font(.caption)
#endif
}
.contentShape(Rectangle())
}
#if os(macOS)
.buttonStyle(.plain)
#elseif os(iOS)
.foregroundStyle(.primary)
#endif
}
.onMove { from, to in
rule.connectionRules.move(fromOffsets: from, toOffset: to)
}
.onDelete { offsets in
rule.connectionRules.remove(atOffsets: offsets)
}
}
} header: {
HStack {
Text("Connection Rules")
Spacer()
Button {
isAddingConnectionRule = true
} label: {
Image(systemName: "plus.circle.fill")
}
#if os(macOS)
.buttonStyle(.plain)
#endif
}
} footer: {
Text("When action is 'Evaluate Connection', these rules determine whether to connect based on the destination host.")
}
}
}
private struct EvaluateConnectionRuleEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var rule: EvaluateConnectionRule
private let onSave: (EvaluateConnectionRule) -> Void
@State private var domainText = ""
@State private var dnsServerText = ""
@State private var dnsServerError: String?
private func isValidIPAddress(_ string: String) -> Bool {
var sin = sockaddr_in()
var sin6 = sockaddr_in6()
return string.withCString { cstring in
inet_pton(AF_INET, cstring, &sin.sin_addr) == 1 ||
inet_pton(AF_INET6, cstring, &sin6.sin6_addr) == 1
}
}
private var isProbeURLValid: Bool {
guard !rule.probeURL.isEmpty else { return true }
guard let url = URL(string: rule.probeURL),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else { return false }
return true
}
private var canSave: Bool {
!rule.matchDomains.isEmpty && isProbeURLValid
}
init(rule: EvaluateConnectionRule, onSave: @escaping (EvaluateConnectionRule) -> Void) {
_rule = State(initialValue: rule)
self.onSave = onSave
}
var body: some View {
Form {
Section {
Picker("Action", selection: $rule.action) {
ForEach(EvaluateConnectionRuleAction.allCases) { action in
Text(action.name).tag(action)
}
}
#if os(iOS)
.pickerStyle(.menu)
#endif
} header: {
Text("Action")
} footer: {
if rule.action == .connectIfNeeded {
Text("Connect VPN if the destination is not directly accessible.")
} else {
Text("Never connect VPN for matching domains.")
}
}
Section {
matchDomainsSection
} header: {
Text("Match Domains")
} footer: {
Text("Domains that trigger this rule. The rule matches if the destination host shares a suffix with any domain in this list.")
}
if rule.action == .connectIfNeeded {
Section {
useDNSServersSection
} header: {
Text("DNS Servers")
} footer: {
Text("DNS servers to use for resolving the destination. If resolution fails, VPN is started.")
}
#if !os(tvOS)
Section {
HStack {
Text("Probe URL")
Spacer()
TextField("http://...", text: $rule.probeURL)
.multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
#endif
}
} header: {
Text("Probe URL")
} footer: {
Text("If set, a request is sent to this URL. If it doesn't return HTTP 200, VPN is started.")
}
#endif
}
}
.navigationTitle("Connection Rule")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
onSave(rule)
dismiss()
}
.disabled(!canSave)
}
}
#if os(macOS)
.formStyle(.grouped)
#endif
}
@ViewBuilder
private var matchDomainsSection: some View {
#if !os(tvOS)
ForEach(rule.matchDomains, id: \.self) { domain in
HStack {
Text(domain)
Spacer()
Button {
rule.matchDomains.removeAll { $0 == domain }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
HStack {
TextField("Add domain (e.g., example.com)", text: $domainText)
.onSubmit {
addDomain()
}
Button {
addDomain()
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
.disabled(domainText.isEmpty)
}
#else
ForEach(rule.matchDomains, id: \.self) { domain in
Text(domain)
}
.onDelete { offsets in
rule.matchDomains.remove(atOffsets: offsets)
}
#endif
}
@ViewBuilder
private var useDNSServersSection: some View {
#if !os(tvOS)
ForEach(rule.useDNSServers, id: \.self) { server in
HStack {
Text(server)
Spacer()
Button {
rule.useDNSServers.removeAll { $0 == server }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
VStack(alignment: .leading) {
HStack {
TextField("Add DNS server IP", text: $dnsServerText)
.onSubmit {
addDNSServer()
}
Button {
addDNSServer()
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
.disabled(dnsServerText.isEmpty)
}
if let error = dnsServerError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
#else
ForEach(rule.useDNSServers, id: \.self) { server in
Text(server)
}
.onDelete { offsets in
rule.useDNSServers.remove(atOffsets: offsets)
}
#endif
}
private func addDomain() {
let trimmed = domainText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !rule.matchDomains.contains(trimmed) {
rule.matchDomains.append(trimmed)
}
domainText = ""
}
private func addDNSServer() {
let trimmed = dnsServerText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
dnsServerText = ""
return
}
guard isValidIPAddress(trimmed) else {
dnsServerError = "Invalid IP address"
return
}
dnsServerError = nil
if !rule.useDNSServers.contains(trimmed) {
rule.useDNSServers.append(trimmed)
}
dnsServerText = ""
}
}
private struct StringListSection: View {
let title: String
let placeholder: String
@Binding var items: [String]
@State private var inputText = ""
var body: some View {
#if !os(tvOS)
DisclosureGroup {
ForEach(items, id: \.self) { item in
HStack {
Text(item)
Spacer()
Button {
items.removeAll { $0 == item }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
HStack {
TextField(placeholder, text: $inputText)
.onSubmit {
addItem()
}
Button {
addItem()
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
.disabled(inputText.isEmpty)
}
} label: {
HStack {
Text(title)
Spacer()
if !items.isEmpty {
Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary)
}
}
}
#else
NavigationLink {
StringListEditView(title: title, placeholder: placeholder, items: $items)
} label: {
HStack {
Text(title)
Spacer()
if !items.isEmpty {
Text(verbatim: "\(items.count)")
.foregroundStyle(.secondary)
}
}
}
#endif
}
private func addItem() {
let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !items.contains(trimmed) {
items.append(trimmed)
}
inputText = ""
}
}
#if os(tvOS)
private struct StringListEditView: View {
let title: String
let placeholder: String
@Binding var items: [String]
@State private var inputText = ""
var body: some View {
List {
Section {
ForEach(items, id: \.self) { item in
Text(item)
}
.onDelete { offsets in
items.remove(atOffsets: offsets)
}
}
Section {
HStack {
TextField(placeholder, text: $inputText)
Button("Add") {
addItem()
}
.disabled(inputText.isEmpty)
}
}
}
.navigationTitle(title)
}
private func addItem() {
let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, !items.contains(trimmed) {
items.append(trimmed)
}
inputText = ""
}
}
#endif