Add custom on-demand rules support

This commit is contained in:
世界
2025-12-30 00:18:43 +08:00
parent 6de410c3f7
commit 58b5e9d732
13 changed files with 1310 additions and 59 deletions
@@ -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
+7 -7
View File
@@ -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)
+1 -1
View File
@@ -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()
}
}
+6 -4
View File
@@ -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
+5 -3
View File
@@ -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()
}
+45 -18
View File
@@ -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 {
+3 -2
View File
@@ -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)")
}
+230
View File
@@ -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
}
}
+142
View File
@@ -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" : {
+1 -1
View File
@@ -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
}
}
}