Add custom on-demand rules support
This commit is contained in:
@@ -1,5 +1,94 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct PlatformSheetSize {
|
||||
let minWidth: CGFloat
|
||||
let minHeight: CGFloat
|
||||
|
||||
public init(minWidth: CGFloat, minHeight: CGFloat) {
|
||||
self.minWidth = minWidth
|
||||
self.minHeight = minHeight
|
||||
}
|
||||
|
||||
public static let `default` = PlatformSheetSize(minWidth: 500, minHeight: 400)
|
||||
public static let small = PlatformSheetSize(minWidth: 400, minHeight: 300)
|
||||
}
|
||||
|
||||
public extension View {
|
||||
func platformSheet(
|
||||
isPresented: Binding<Bool>,
|
||||
size: PlatformSheetSize = .default,
|
||||
@ViewBuilder content: @escaping () -> some View
|
||||
) -> some View {
|
||||
modifier(PlatformSheetModifier(isPresented: isPresented, size: size, content: content))
|
||||
}
|
||||
|
||||
func platformSheet<Item: Identifiable>(
|
||||
item: Binding<Item?>,
|
||||
size: PlatformSheetSize = .default,
|
||||
@ViewBuilder content: @escaping (Item) -> some View
|
||||
) -> some View {
|
||||
modifier(PlatformSheetItemModifier(item: item, size: size, content: content))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlatformSheetModifier<SheetContent: View>: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
let size: PlatformSheetSize
|
||||
@ViewBuilder let content: () -> SheetContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
content.sheet(isPresented: $isPresented) {
|
||||
NavigationStackCompat {
|
||||
self.content()
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
content.sheet(isPresented: $isPresented) {
|
||||
NavigationStackCompat {
|
||||
self.content()
|
||||
}
|
||||
.frame(minWidth: size.minWidth, minHeight: size.minHeight)
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
content.fullScreenCover(isPresented: $isPresented) {
|
||||
NavigationStackCompat {
|
||||
self.content()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlatformSheetItemModifier<Item: Identifiable, SheetContent: View>: ViewModifier {
|
||||
@Binding var item: Item?
|
||||
let size: PlatformSheetSize
|
||||
@ViewBuilder let content: (Item) -> SheetContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(iOS)
|
||||
content.sheet(item: $item) { item in
|
||||
NavigationStackCompat {
|
||||
self.content(item)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
content.sheet(item: $item) { item in
|
||||
NavigationStackCompat {
|
||||
self.content(item)
|
||||
}
|
||||
.frame(minWidth: size.minWidth, minHeight: size.minHeight)
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
content.fullScreenCover(item: $item) { item in
|
||||
NavigationStackCompat {
|
||||
self.content(item)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
@ViewBuilder
|
||||
func presentationDetentsIfAvailable() -> some View {
|
||||
|
||||
@@ -87,6 +87,7 @@ public extension NavigationPage {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@MainActor
|
||||
func visible(_ profile: ExtensionProfile?) -> Bool {
|
||||
switch self {
|
||||
case .groups, .connections:
|
||||
|
||||
@@ -7,60 +7,805 @@ public struct OnDemandRulesView: View {
|
||||
@State private var isLoading = true
|
||||
@State private var alert: AlertState?
|
||||
@State private var alwaysOn = false
|
||||
@State private var onDemandEnabled = false
|
||||
@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 {
|
||||
Task.detached {
|
||||
loadTask = Task {
|
||||
await loadSettings()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FormView {
|
||||
FormToggle("Always On", """
|
||||
Implement always-on via on-demand rules.
|
||||
alwaysOnToggle
|
||||
enableToggle
|
||||
|
||||
This should not be an intended use of the API, so you cannot disable VPN in system settings. To stop the service manually, use the in-app interface or simply delete the VPN profile.
|
||||
""", $alwaysOn) { newValue in
|
||||
await SharedPreferences.alwaysOn.set(newValue)
|
||||
await restartService()
|
||||
if !alwaysOn, onDemandEnabled {
|
||||
rulesSection
|
||||
}
|
||||
|
||||
FormButton {
|
||||
Task {
|
||||
await SharedPreferences.resetOnDemandRules()
|
||||
await restartService()
|
||||
isLoading = true
|
||||
}
|
||||
} label: {
|
||||
Label("Reset", systemImage: "eraser.fill")
|
||||
}
|
||||
.foregroundColor(.red)
|
||||
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
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var alwaysOnToggle: some View {
|
||||
FormToggle("Always On", """
|
||||
Automatically connect VPN on any network.
|
||||
|
||||
When enabled, VPN connects automatically when network is available. Custom rules below will be disabled.
|
||||
""", $alwaysOn) { newValue in
|
||||
await SharedPreferences.alwaysOn.set(newValue)
|
||||
await updateService()
|
||||
}
|
||||
}
|
||||
|
||||
private var enableToggle: some View {
|
||||
FormToggle("Custom Rules", """
|
||||
Automatically connect or disconnect VPN based on custom rules.
|
||||
|
||||
When enabled, iOS manages VPN state automatically. You may need to use the in-app interface to stop the service.
|
||||
""", $onDemandEnabled) { newValue in
|
||||
await SharedPreferences.onDemandEnabled.set(newValue)
|
||||
await updateService()
|
||||
}
|
||||
.disabled(alwaysOn)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rulesSection: some View {
|
||||
Section {
|
||||
if rules.isEmpty {
|
||||
Text("No rules configured. Add a rule to specify when VPN should connect or disconnect.")
|
||||
.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()
|
||||
Button {
|
||||
isAddingRule = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(.plain)
|
||||
#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 {
|
||||
Button {
|
||||
editingRule = rule
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
actionIcon(rule.action)
|
||||
Text(rule.action.name)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
Text(ruleDescription(rule))
|
||||
.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
|
||||
}
|
||||
|
||||
private func restartService() async {
|
||||
@ViewBuilder
|
||||
private func actionIcon(_ action: OnDemandRuleAction) -> some View {
|
||||
switch action {
|
||||
case .connect:
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case .disconnect:
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
case .evaluateConnection:
|
||||
Image(systemName: "questionmark.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
case .ignore:
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
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(error: error)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Reset", systemImage: "eraser.fill")
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
private func updateService() async {
|
||||
guard let profile = environments.extensionProfile, profile.status.isConnected else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await profile.restart()
|
||||
let alwaysOnValue = await SharedPreferences.alwaysOn.get()
|
||||
let onDemandEnabledValue = await SharedPreferences.onDemandEnabled.get()
|
||||
let enabled = alwaysOnValue || onDemandEnabledValue
|
||||
try await profile.updateOnDemand(enabled: enabled, useDefaultRules: alwaysOnValue)
|
||||
} catch {
|
||||
alert = AlertState(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveRules() async {
|
||||
await SharedPreferences.onDemandRules.set(rules)
|
||||
let savedRules = await SharedPreferences.onDemandRules.get()
|
||||
if savedRules != rules {
|
||||
alert = AlertState(errorMessage: "Failed to save rules")
|
||||
return
|
||||
}
|
||||
await updateService()
|
||||
}
|
||||
|
||||
private func loadSettings() async {
|
||||
alwaysOn = await SharedPreferences.alwaysOn.get()
|
||||
onDemandEnabled = await SharedPreferences.onDemandEnabled.get()
|
||||
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
|
||||
}
|
||||
}
|
||||
.navigationTitle(isNew ? "New Rule" : "Edit Rule")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
onSave(rule)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!isProbeURLValid)
|
||||
}
|
||||
}
|
||||
#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
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
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("\(items.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
NavigationLink {
|
||||
StringListEditView(title: title, placeholder: placeholder, items: $items)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer()
|
||||
if !items.isEmpty {
|
||||
Text("\(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
|
||||
|
||||
@@ -32,12 +32,12 @@ struct StartServiceIntent: AppIntent {
|
||||
} else if profile != "default" {
|
||||
throw NSError(domain: "Specified profile not found: \(profile)", code: 0)
|
||||
}
|
||||
if extensionProfile.status == .connected {
|
||||
if await extensionProfile.status == .connected {
|
||||
if !profileChanged {
|
||||
return .result(dialog: "Service is already running")
|
||||
}
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
} else if extensionProfile.status.isConnected {
|
||||
} else if await extensionProfile.status.isConnected {
|
||||
try await extensionProfile.restart()
|
||||
} else {
|
||||
try await extensionProfile.start()
|
||||
@@ -60,9 +60,9 @@ struct RestartServiceIntent: AppIntent {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result(dialog: "Service is not installed")
|
||||
}
|
||||
if extensionProfile.status == .connected {
|
||||
if await extensionProfile.status == .connected {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
} else if extensionProfile.status.isConnected {
|
||||
} else if await extensionProfile.status.isConnected {
|
||||
try await extensionProfile.restart()
|
||||
} else {
|
||||
try await extensionProfile.start()
|
||||
@@ -104,7 +104,7 @@ struct ToggleServiceIntent: AppIntent {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result(value: false)
|
||||
}
|
||||
if extensionProfile.status.isConnected {
|
||||
if await extensionProfile.status.isConnected {
|
||||
try await extensionProfile.stop()
|
||||
return .result(value: false)
|
||||
|
||||
@@ -129,7 +129,7 @@ struct GetServiceStatus: AppIntent {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return .result(value: false)
|
||||
}
|
||||
return .result(value: extensionProfile.status.isConnected)
|
||||
return await .result(value: extensionProfile.status.isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ struct GetCurrentProfile: AppIntent {
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<String> {
|
||||
guard let profile = try await ProfileManager.get(SharedPreferences.selectedProfileID.get()) else {
|
||||
guard let profile = try await ProfileManager.get(await SharedPreferences.selectedProfileID.get()) else {
|
||||
throw NSError(domain: "No profile selected", code: 0)
|
||||
}
|
||||
return .result(value: profile.name)
|
||||
|
||||
@@ -28,7 +28,7 @@ public extension Profile {
|
||||
nonisolated func onProfileUpdated() async throws {
|
||||
if await SharedPreferences.selectedProfileID.get() == id {
|
||||
if let profile = try? await ExtensionProfile.load() {
|
||||
if profile.status == .connected {
|
||||
if await profile.status == .connected {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceReload()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ public enum SharedPreferences {
|
||||
public static let ignoreMemoryLimit = Preference<Bool>("ignore_memory_limit", defaultValue: ignoreMemoryLimitByDefault)
|
||||
|
||||
#if os(iOS)
|
||||
public static let excludeLocalNetworksByDefault = true
|
||||
private static let excludeLocalNetworksByDefault = true
|
||||
#elseif os(macOS)
|
||||
public static let excludeLocalNetworksByDefault = false
|
||||
private static let excludeLocalNetworksByDefault = false
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
@@ -83,9 +83,11 @@ public enum SharedPreferences {
|
||||
// On Demand Rules
|
||||
|
||||
public static let alwaysOn = Preference<Bool>("always_on", defaultValue: false)
|
||||
public static let onDemandEnabled = Preference<Bool>("on_demand_enabled", defaultValue: false)
|
||||
public static let onDemandRules = Preference<[OnDemandRule]>("on_demand_rules", defaultValue: [])
|
||||
|
||||
public static func resetOnDemandRules() async {
|
||||
try? await batchDelete([alwaysOn.name])
|
||||
public static func resetOnDemandRules() async throws {
|
||||
try await batchDelete([alwaysOn.name, onDemandEnabled.name, onDemandRules.name])
|
||||
}
|
||||
|
||||
// Core
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public class ExtensionEnvironments: ObservableObject {
|
||||
@Published public var commandClient = CommandClient([.log, .status, .groups, .clashMode, .connections])
|
||||
@Published public var extensionProfileLoading = true
|
||||
@@ -13,8 +14,10 @@ public class ExtensionEnvironments: ObservableObject {
|
||||
|
||||
public init() {}
|
||||
|
||||
deinit {
|
||||
commandClient.disconnect()
|
||||
nonisolated deinit {
|
||||
Task { @MainActor in
|
||||
commandClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public func postReload() {
|
||||
@@ -23,7 +26,6 @@ public class ExtensionEnvironments: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public func reload() async {
|
||||
if let newProfile = try? await ExtensionProfile.load() {
|
||||
if extensionProfile == nil || extensionProfile?.status == .invalid {
|
||||
|
||||
@@ -248,12 +248,12 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
|
||||
}
|
||||
|
||||
private func onUpdateDefaultInterface(_ listener: LibboxInterfaceUpdateListenerProtocol, _ path: Network.NWPath) {
|
||||
if path.status == .unsatisfied {
|
||||
guard path.status != .unsatisfied,
|
||||
let defaultInterface = path.availableInterfaces.first else {
|
||||
listener.updateDefaultInterface("", interfaceIndex: -1, isExpensive: false, isConstrained: false)
|
||||
} else {
|
||||
let defaultInterface = path.availableInterfaces.first!
|
||||
listener.updateDefaultInterface(defaultInterface.name, interfaceIndex: Int32(defaultInterface.index), isExpensive: path.isExpensive, isConstrained: path.isConstrained)
|
||||
return
|
||||
}
|
||||
listener.updateDefaultInterface(defaultInterface.name, interfaceIndex: Int32(defaultInterface.index), isExpensive: path.isExpensive, isConstrained: path.isConstrained)
|
||||
}
|
||||
|
||||
public func closeDefaultInterfaceMonitor(_: LibboxInterfaceUpdateListenerProtocol?) throws {
|
||||
@@ -356,6 +356,18 @@ public class ExtensionPlatformInterface: NSObject, LibboxPlatformInterfaceProtoc
|
||||
#endif
|
||||
}
|
||||
|
||||
public func readWIFISSID() -> String? {
|
||||
#if os(iOS)
|
||||
return runBlocking {
|
||||
await NEHotspotNetwork.fetchCurrent()?.ssid
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return CWWiFiClient.shared().interface()?.ssid()
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
public func serviceStop() throws {
|
||||
tunnel.stopService()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import Libbox
|
||||
import NetworkExtension
|
||||
|
||||
@MainActor
|
||||
public class ExtensionProfile: ObservableObject {
|
||||
public static let controlKind = "io.nekohasekai.sfavt.widget.ServiceToggle"
|
||||
|
||||
@@ -28,9 +29,12 @@ public class ExtensionProfile: ObservableObject {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.connection = notification.object as! NEVPNConnection
|
||||
self.status = self.connection.status
|
||||
self.connectedDate = self.connection.connectedDate
|
||||
guard let connection = notification.object as? NEVPNConnection else {
|
||||
return
|
||||
}
|
||||
self.connection = connection
|
||||
self.status = connection.status
|
||||
self.connectedDate = connection.connectedDate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +44,31 @@ public class ExtensionProfile: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func setOnDemandRules() {
|
||||
let interfaceRule = NEOnDemandRuleConnect()
|
||||
interfaceRule.interfaceTypeMatch = .any
|
||||
let probeRule = NEOnDemandRuleConnect()
|
||||
probeRule.probeURL = URL(string: "http://captive.apple.com")
|
||||
manager.onDemandRules = [interfaceRule, probeRule]
|
||||
nonisolated deinit {
|
||||
if let observer {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
public func updateAlwaysOn(_ newState: Bool) async throws {
|
||||
manager.isOnDemandEnabled = newState
|
||||
setOnDemandRules()
|
||||
private static func makeDefaultOnDemandRules() -> [NEOnDemandRule] {
|
||||
let rule = NEOnDemandRuleConnect()
|
||||
rule.interfaceTypeMatch = .any
|
||||
rule.probeURL = URL(string: "http://captive.apple.com")
|
||||
return [rule]
|
||||
}
|
||||
|
||||
private func setOnDemandRules(useDefaultRules: Bool) async {
|
||||
if useDefaultRules {
|
||||
manager.onDemandRules = Self.makeDefaultOnDemandRules()
|
||||
} else {
|
||||
let rules = await SharedPreferences.onDemandRules.get()
|
||||
manager.onDemandRules = rules.isEmpty ? Self.makeDefaultOnDemandRules() : rules.map { $0.toNERule() }
|
||||
}
|
||||
}
|
||||
|
||||
public func updateOnDemand(enabled: Bool, useDefaultRules: Bool) async throws {
|
||||
manager.isOnDemandEnabled = enabled
|
||||
await setOnDemandRules(useDefaultRules: useDefaultRules)
|
||||
try await manager.saveToPreferences()
|
||||
}
|
||||
|
||||
@@ -62,9 +80,11 @@ public class ExtensionProfile: ObservableObject {
|
||||
public func start() async throws {
|
||||
await fetchProfile()
|
||||
manager.isEnabled = true
|
||||
if await SharedPreferences.alwaysOn.get() {
|
||||
let alwaysOn = await SharedPreferences.alwaysOn.get()
|
||||
let onDemandEnabled = await SharedPreferences.onDemandEnabled.get()
|
||||
if alwaysOn || onDemandEnabled {
|
||||
manager.isOnDemandEnabled = true
|
||||
setOnDemandRules()
|
||||
await setOnDemandRules(useDefaultRules: alwaysOn)
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if let protocolConfiguration = manager.protocolConfiguration {
|
||||
@@ -80,11 +100,14 @@ public class ExtensionProfile: ObservableObject {
|
||||
if Variant.useSystemExtension {
|
||||
try manager.connection.startVPNTunnel(options: [
|
||||
"username": NSString(string: NSUserName()),
|
||||
"manualStart": NSNumber(value: true),
|
||||
])
|
||||
return
|
||||
}
|
||||
#endif
|
||||
try manager.connection.startVPNTunnel()
|
||||
try manager.connection.startVPNTunnel(options: [
|
||||
"manualStart": NSNumber(value: true),
|
||||
])
|
||||
}
|
||||
|
||||
public func fetchProfile() async {
|
||||
@@ -94,7 +117,9 @@ public class ExtensionProfile: ObservableObject {
|
||||
_ = try profile.read()
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
NSLog("fetchProfile error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
@@ -104,14 +129,16 @@ public class ExtensionProfile: ObservableObject {
|
||||
}
|
||||
do {
|
||||
try LibboxNewStandaloneCommandClient()!.serviceClose()
|
||||
} catch {}
|
||||
} catch {
|
||||
NSLog("serviceClose error: \(error.localizedDescription)")
|
||||
}
|
||||
manager.connection.stopVPNTunnel()
|
||||
}
|
||||
|
||||
public func restart() async throws {
|
||||
try await stop()
|
||||
var waitSeconds = 0
|
||||
while await MainActor.run(body: { status }) != .disconnected {
|
||||
while status != .disconnected {
|
||||
try await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
waitSeconds += 1
|
||||
if waitSeconds >= 5 {
|
||||
|
||||
@@ -13,7 +13,7 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
private var commandServer: LibboxCommandServer!
|
||||
private var platformInterface: ExtensionPlatformInterface!
|
||||
|
||||
override open func startTunnel(options _: [String: NSObject]?) async throws {
|
||||
override open func startTunnel(options startOptions: [String: NSObject]?) async throws {
|
||||
let options = LibboxSetupOptions()
|
||||
options.basePath = FilePath.sharedDirectory.relativePath
|
||||
options.workingPath = FilePath.workingDirectory.relativePath
|
||||
@@ -68,9 +68,10 @@ open class ExtensionProvider: NEPacketTunnelProvider {
|
||||
}
|
||||
|
||||
private func startService() async throws {
|
||||
let profileID = await SharedPreferences.selectedProfileID.get()
|
||||
let profile: Profile?
|
||||
do {
|
||||
profile = try await ProfileManager.get(Int64(SharedPreferences.selectedProfileID.get()))
|
||||
profile = try await ProfileManager.get(profileID)
|
||||
} catch {
|
||||
throw ExtensionStartupError("(packet-tunnel) error: read selected profile: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
public enum OnDemandRuleAction: Int, Codable, CaseIterable, Identifiable {
|
||||
case connect = 1
|
||||
case disconnect = 2
|
||||
case evaluateConnection = 3
|
||||
case ignore = 4
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .connect:
|
||||
return NSLocalizedString("Connect", comment: "")
|
||||
case .disconnect:
|
||||
return NSLocalizedString("Disconnect", comment: "")
|
||||
case .evaluateConnection:
|
||||
return NSLocalizedString("Evaluate Connection", comment: "")
|
||||
case .ignore:
|
||||
return NSLocalizedString("Ignore", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
public var actionDescription: String {
|
||||
switch self {
|
||||
case .connect:
|
||||
return NSLocalizedString("Start the VPN connection when conditions match.", comment: "")
|
||||
case .disconnect:
|
||||
return NSLocalizedString("Stop the VPN connection when conditions match.", comment: "")
|
||||
case .evaluateConnection:
|
||||
return NSLocalizedString("Evaluate the destination host before deciding to connect.", comment: "")
|
||||
case .ignore:
|
||||
return NSLocalizedString("Leave the VPN connection in its current state.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum OnDemandRuleInterfaceType: Int, Codable, Identifiable {
|
||||
case any = 0
|
||||
#if os(macOS) || os(tvOS)
|
||||
case ethernet = 1
|
||||
#endif
|
||||
case wifi = 2
|
||||
#if os(iOS)
|
||||
case cellular = 3
|
||||
#endif
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .any:
|
||||
return NSLocalizedString("Any", comment: "")
|
||||
#if os(macOS) || os(tvOS)
|
||||
case .ethernet:
|
||||
return NSLocalizedString("Ethernet", comment: "")
|
||||
#endif
|
||||
case .wifi:
|
||||
return NSLocalizedString("Wi-Fi", comment: "")
|
||||
#if os(iOS)
|
||||
case .cellular:
|
||||
return NSLocalizedString("Cellular", comment: "")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public static var availableCases: [OnDemandRuleInterfaceType] {
|
||||
#if os(iOS)
|
||||
return [.any, .wifi, .cellular]
|
||||
#elseif os(macOS)
|
||||
return [.any, .ethernet, .wifi]
|
||||
#elseif os(tvOS)
|
||||
return [.any, .ethernet, .wifi]
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public enum EvaluateConnectionRuleAction: Int, Codable, CaseIterable, Identifiable {
|
||||
case connectIfNeeded = 1
|
||||
case neverConnect = 2
|
||||
|
||||
public var id: Int { rawValue }
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .connectIfNeeded:
|
||||
return NSLocalizedString("Connect If Needed", comment: "")
|
||||
case .neverConnect:
|
||||
return NSLocalizedString("Never Connect", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct EvaluateConnectionRule: Codable, Identifiable, Hashable {
|
||||
public var id = UUID()
|
||||
public var action: EvaluateConnectionRuleAction = .connectIfNeeded
|
||||
public var matchDomains: [String] = []
|
||||
public var useDNSServers: [String] = []
|
||||
public var probeURL: String = ""
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case action
|
||||
case matchDomains
|
||||
case useDNSServers
|
||||
case probeURL
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(action: EvaluateConnectionRuleAction, matchDomains: [String], useDNSServers: [String] = [], probeURL: String = "") {
|
||||
self.action = action
|
||||
self.matchDomains = matchDomains
|
||||
self.useDNSServers = useDNSServers
|
||||
self.probeURL = probeURL
|
||||
}
|
||||
|
||||
func toNERule() -> NEEvaluateConnectionRule {
|
||||
let neAction: NEEvaluateConnectionRuleAction
|
||||
switch action {
|
||||
case .connectIfNeeded:
|
||||
neAction = .connectIfNeeded
|
||||
case .neverConnect:
|
||||
neAction = .neverConnect
|
||||
}
|
||||
let rule = NEEvaluateConnectionRule(matchDomains: matchDomains, andAction: neAction)
|
||||
if !useDNSServers.isEmpty {
|
||||
rule.useDNSServers = useDNSServers
|
||||
}
|
||||
if !probeURL.isEmpty, let url = URL(string: probeURL) {
|
||||
rule.probeURL = url
|
||||
}
|
||||
return rule
|
||||
}
|
||||
}
|
||||
|
||||
public struct OnDemandRule: Codable, Identifiable, Hashable {
|
||||
public var id = UUID()
|
||||
public var action: OnDemandRuleAction = .connect
|
||||
public var interfaceType: OnDemandRuleInterfaceType = .any
|
||||
public var ssidMatch: [String] = []
|
||||
public var dnsSearchDomainMatch: [String] = []
|
||||
public var dnsServerAddressMatch: [String] = []
|
||||
public var probeURL: String = ""
|
||||
public var connectionRules: [EvaluateConnectionRule] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case action
|
||||
case interfaceType
|
||||
case ssidMatch
|
||||
case dnsSearchDomainMatch
|
||||
case dnsServerAddressMatch
|
||||
case probeURL
|
||||
case connectionRules
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(
|
||||
action: OnDemandRuleAction,
|
||||
interfaceType: OnDemandRuleInterfaceType = .any,
|
||||
ssidMatch: [String] = [],
|
||||
dnsSearchDomainMatch: [String] = [],
|
||||
dnsServerAddressMatch: [String] = [],
|
||||
probeURL: String = "",
|
||||
connectionRules: [EvaluateConnectionRule] = []
|
||||
) {
|
||||
self.action = action
|
||||
self.interfaceType = interfaceType
|
||||
self.ssidMatch = ssidMatch
|
||||
self.dnsSearchDomainMatch = dnsSearchDomainMatch
|
||||
self.dnsServerAddressMatch = dnsServerAddressMatch
|
||||
self.probeURL = probeURL
|
||||
self.connectionRules = connectionRules
|
||||
}
|
||||
|
||||
func toNERule() -> NEOnDemandRule {
|
||||
let rule: NEOnDemandRule
|
||||
switch action {
|
||||
case .connect:
|
||||
rule = NEOnDemandRuleConnect()
|
||||
case .disconnect:
|
||||
rule = NEOnDemandRuleDisconnect()
|
||||
case .ignore:
|
||||
rule = NEOnDemandRuleIgnore()
|
||||
case .evaluateConnection:
|
||||
let evalRule = NEOnDemandRuleEvaluateConnection()
|
||||
let validRules = connectionRules.filter { !$0.matchDomains.isEmpty }
|
||||
if !validRules.isEmpty {
|
||||
evalRule.connectionRules = validRules.map { $0.toNERule() }
|
||||
}
|
||||
rule = evalRule
|
||||
}
|
||||
|
||||
switch interfaceType {
|
||||
case .any:
|
||||
rule.interfaceTypeMatch = .any
|
||||
#if os(macOS) || os(tvOS)
|
||||
case .ethernet:
|
||||
rule.interfaceTypeMatch = .ethernet
|
||||
#endif
|
||||
case .wifi:
|
||||
rule.interfaceTypeMatch = .wiFi
|
||||
#if os(iOS)
|
||||
case .cellular:
|
||||
rule.interfaceTypeMatch = .cellular
|
||||
#endif
|
||||
}
|
||||
|
||||
if !ssidMatch.isEmpty {
|
||||
rule.ssidMatch = ssidMatch
|
||||
}
|
||||
|
||||
if !dnsSearchDomainMatch.isEmpty {
|
||||
rule.dnsSearchDomainMatch = dnsSearchDomainMatch
|
||||
}
|
||||
|
||||
if !dnsServerAddressMatch.isEmpty {
|
||||
rule.dnsServerAddressMatch = dnsServerAddressMatch
|
||||
}
|
||||
|
||||
if !probeURL.isEmpty, let url = URL(string: probeURL) {
|
||||
rule.probeURL = url
|
||||
}
|
||||
|
||||
return rule
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add DNS server IP" : {
|
||||
|
||||
},
|
||||
"Add domain" : {
|
||||
|
||||
},
|
||||
"Add domain (e.g., example.com)" : {
|
||||
|
||||
},
|
||||
"Add SSID" : {
|
||||
|
||||
},
|
||||
"All" : {
|
||||
"localizations" : {
|
||||
@@ -107,6 +119,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"All specified conditions must match for the rule to apply. Leave empty to match any network." : {
|
||||
|
||||
},
|
||||
"Always On" : {
|
||||
"localizations" : {
|
||||
@@ -117,6 +132,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Any" : {
|
||||
|
||||
},
|
||||
"App" : {
|
||||
"localizations" : {
|
||||
@@ -203,6 +221,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Automatically connect or disconnect VPN based on custom rules.\n\nWhen enabled, iOS manages VPN state automatically. You may need to use the in-app interface to stop the service." : {
|
||||
|
||||
},
|
||||
"Automatically connect VPN on any network.\n\nWhen enabled, VPN connects automatically when network is available. Custom rules below will be disabled." : {
|
||||
|
||||
},
|
||||
"Browse" : {
|
||||
"localizations" : {
|
||||
@@ -243,6 +267,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Cellular" : {
|
||||
|
||||
},
|
||||
"Chain" : {
|
||||
"localizations" : {
|
||||
@@ -334,6 +361,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Conditions" : {
|
||||
|
||||
},
|
||||
"Configuration" : {
|
||||
"localizations" : {
|
||||
@@ -344,6 +374,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connect" : {
|
||||
|
||||
},
|
||||
"Connect If Needed" : {
|
||||
|
||||
},
|
||||
"Connect VPN if the destination is not directly accessible." : {
|
||||
|
||||
},
|
||||
"Connecting..." : {
|
||||
"localizations" : {
|
||||
@@ -364,6 +403,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connection Rule" : {
|
||||
|
||||
},
|
||||
"Connection Rules" : {
|
||||
|
||||
},
|
||||
"Connections" : {
|
||||
"localizations" : {
|
||||
@@ -445,6 +490,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Custom Rules" : {
|
||||
|
||||
},
|
||||
"Dashboard" : {
|
||||
"localizations" : {
|
||||
@@ -556,6 +604,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Disconnect" : {
|
||||
|
||||
},
|
||||
"DNS Search Domain" : {
|
||||
|
||||
},
|
||||
"DNS Server Address" : {
|
||||
|
||||
},
|
||||
"DNS Servers" : {
|
||||
|
||||
},
|
||||
"DNS servers to use for resolving the destination. If resolution fails, VPN is started." : {
|
||||
|
||||
},
|
||||
"Do not enforce memory limits on sing-box. Will cause OOM on non-jailbroken iOS and tvOS devices." : {
|
||||
"localizations" : {
|
||||
@@ -606,6 +669,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Domains that trigger this rule. The rule matches if the destination host shares a suffix with any domain in this list." : {
|
||||
|
||||
},
|
||||
"Don't Save" : {
|
||||
"localizations" : {
|
||||
@@ -616,6 +682,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Don't Switch" : {
|
||||
|
||||
},
|
||||
"Done" : {
|
||||
"localizations" : {
|
||||
@@ -676,6 +745,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit Rule" : {
|
||||
|
||||
},
|
||||
"Empty connections" : {
|
||||
"localizations" : {
|
||||
@@ -739,6 +811,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Ethernet" : {
|
||||
|
||||
},
|
||||
"Evaluate Connection" : {
|
||||
|
||||
},
|
||||
"Evaluate the destination host before deciding to connect." : {
|
||||
|
||||
},
|
||||
"Exclude APNs Route" : {
|
||||
"localizations" : {
|
||||
@@ -893,6 +974,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://..." : {
|
||||
|
||||
},
|
||||
"https://sing-box.sagernet.org/" : {
|
||||
"localizations" : {
|
||||
@@ -926,6 +1010,9 @@
|
||||
},
|
||||
"iCloud" : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"If set, a request is sent to this URL. If it doesn't return HTTP 200, VPN is started." : {
|
||||
|
||||
},
|
||||
"If this property is true when the **includeAllNetworks** property is false, the system scopes the included routes to the VPN and the excluded routes to the current primary network interface. This property supersedes the system routing table and scoping operations by apps.\n\nIf you set both the **enforceRoutes** and **excludeLocalNetworks** properties to true, the system excludes network connections to hosts on the local network.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3689459-enforceroutes)" : {
|
||||
"shouldTranslate" : false
|
||||
@@ -941,6 +1028,9 @@
|
||||
},
|
||||
"If this property is true, the system routes network traffic through the tunnel except traffic for designated system services necessary for maintaining expected device functionality. You can exclude some types of traffic using the **excludeAPNs**, **excludeLocalNetworks**, and **excludeCellularServices** properties in combination with this property.\n\nwhen enabled, the default TUN stack is changed to `gvisor`, and the `system` and `mixed` stacks are not available.\n\n[Apple Documentation](https://developer.apple.com/documentation/networkextension/nevpnprotocol/3131931-includeallnetworks)" : {
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"Ignore" : {
|
||||
|
||||
},
|
||||
"Ignore Memory Limit" : {
|
||||
"localizations" : {
|
||||
@@ -953,6 +1043,7 @@
|
||||
}
|
||||
},
|
||||
"Implement always-on via on-demand rules.\n\nThis should not be an intended use of the API, so you cannot disable VPN in system settings. To stop the service manually, use the in-app interface or simply delete the VPN profile." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
@@ -1064,6 +1155,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Interface Type" : {
|
||||
|
||||
},
|
||||
"Invalid QR Code" : {
|
||||
"localizations" : {
|
||||
@@ -1124,6 +1218,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Leave the VPN connection in its current state." : {
|
||||
|
||||
},
|
||||
"Loading..." : {
|
||||
"localizations" : {
|
||||
@@ -1179,6 +1276,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Match Domains" : {
|
||||
|
||||
},
|
||||
"Match Rule" : {
|
||||
"shouldTranslate" : false
|
||||
@@ -1282,6 +1382,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Never Connect" : {
|
||||
|
||||
},
|
||||
"Never connect VPN for matching domains." : {
|
||||
|
||||
},
|
||||
"New Profile" : {
|
||||
"localizations" : {
|
||||
@@ -1292,6 +1398,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Rule" : {
|
||||
|
||||
},
|
||||
"No connection rules. Add rules to specify which domains trigger VPN connection." : {
|
||||
|
||||
},
|
||||
"No Default Route" : {
|
||||
"localizations" : {
|
||||
@@ -1302,6 +1414,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No rules configured. Add a rule to specify when VPN should connect or disconnect." : {
|
||||
|
||||
},
|
||||
"Ok" : {
|
||||
"localizations" : {
|
||||
@@ -1444,6 +1559,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Probe URL" : {
|
||||
|
||||
},
|
||||
"Profile" : {
|
||||
"localizations" : {
|
||||
@@ -1568,6 +1686,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rules" : {
|
||||
|
||||
},
|
||||
"Rules are evaluated in order from top to bottom. The first matching rule determines the action." : {
|
||||
|
||||
},
|
||||
"Save" : {
|
||||
"localizations" : {
|
||||
@@ -1781,6 +1905,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SSID Match" : {
|
||||
|
||||
},
|
||||
"Start" : {
|
||||
"localizations" : {
|
||||
@@ -1801,6 +1928,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Start the VPN connection when conditions match." : {
|
||||
|
||||
},
|
||||
"State" : {
|
||||
"localizations" : {
|
||||
@@ -1841,6 +1971,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Stop the VPN connection when conditions match." : {
|
||||
|
||||
},
|
||||
"Switch Profile" : {
|
||||
|
||||
},
|
||||
"System Extension" : {
|
||||
"localizations" : {
|
||||
@@ -2087,6 +2223,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"When action is 'Evaluate Connection', these rules determine whether to connect based on the destination host." : {
|
||||
|
||||
},
|
||||
"Wi-Fi" : {
|
||||
|
||||
},
|
||||
"Working Directory" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -34,7 +34,7 @@ extension ServiceToggleControl {
|
||||
guard let extensionProfile = try await (ExtensionProfile.load()) else {
|
||||
return false
|
||||
}
|
||||
return extensionProfile.status.isStarted
|
||||
return await extensionProfile.status.isStarted
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user