From caaf87e4415f07b00ec35cd343e6f557970d1cea Mon Sep 17 00:00:00 2001 From: YinMo19 Date: Tue, 13 Jan 2026 14:18:56 +0800 Subject: [PATCH 1/7] feat: add gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b5ed3ab..9699471 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ EasyTier.xcodeproj/project.xcworkspace/xcuserdata/ +compile_commands.json + +CLAUDE.md From d08a65292b7e2fac5fcb2b834bd268664970d910 Mon Sep 17 00:00:00 2001 From: YinMo19 Date: Tue, 13 Jan 2026 14:43:08 +0800 Subject: [PATCH 2/7] feat: realise i18n for shortcuts --- EasyTier/Localizable.xcstrings | 147 ++++++++++++++++ EasyTier/Shortcuts/EasyTierShortcuts.swift | 186 +++++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 EasyTier/Shortcuts/EasyTierShortcuts.swift diff --git a/EasyTier/Localizable.xcstrings b/EasyTier/Localizable.xcstrings index 80e0432..ecfc4a9 100644 --- a/EasyTier/Localizable.xcstrings +++ b/EasyTier/Localizable.xcstrings @@ -12,6 +12,9 @@ }, "/" : { "shouldTranslate" : false + }, + "%@" : { + }, "%@ ms" : { "shouldTranslate" : false @@ -500,6 +503,38 @@ } } }, + "connect_easytier" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect EasyTier" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接 EasyTier" + } + } + } + }, + "connect_to_easytier_network" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect to an EasyTier network" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接到 EasyTier 网络" + } + } + } + }, "connection_%@" : { "localizations" : { "en" : { @@ -516,6 +551,22 @@ } } }, + "connection_failed %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection failed: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接失败:%@" + } + } + } + }, "connections" : { "localizations" : { "en" : { @@ -887,6 +938,38 @@ } } }, + "disconnect_easytier" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect EasyTier" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开 EasyTier" + } + } + } + }, + "disconnect_from_easytier_network" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect from EasyTier network" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从 EasyTier 网络断开" + } + } + } + }, "download" : { "localizations" : { "en" : { @@ -909,6 +992,22 @@ "EasyTier" : { "shouldTranslate" : false }, + "easytier_network" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "EasyTier Network" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "EasyTier 网络" + } + } + } + }, "EasyTier-iOS" : { "shouldTranslate" : false }, @@ -2035,6 +2134,22 @@ } } }, + "no_network_profile_found" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No EasyTier network profile found" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 EasyTier 网络配置" + } + } + } + }, "no_network_selected" : { "localizations" : { "en" : { @@ -3094,6 +3209,38 @@ } } }, + "toggle_easytier" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle EasyTier" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换 EasyTier" + } + } + } + }, + "toggle_easytier_network_connection" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle EasyTier network connection" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换 EasyTier 网络连接" + } + } + } + }, "tunnel_proto" : { "localizations" : { "en" : { diff --git a/EasyTier/Shortcuts/EasyTierShortcuts.swift b/EasyTier/Shortcuts/EasyTierShortcuts.swift new file mode 100644 index 0000000..01178ad --- /dev/null +++ b/EasyTier/Shortcuts/EasyTierShortcuts.swift @@ -0,0 +1,186 @@ +import AppIntents +import SwiftData +import NetworkExtension +import SwiftUI + +// MARK: - App Entity + +struct NetworkProfileEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "easytier_network" + static var defaultQuery = NetworkProfileQuery() + + var id: UUID + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } + + init(id: UUID, name: String) { + self.id = id + self.name = name + } + + init(from profile: ProfileSummary) { + self.id = profile.id + self.name = profile.name + } +} + +struct NetworkProfileQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [NetworkProfileEntity] { + let modelContext = ModelContext(try ModelContainer(for: ProfileSummary.self, NetworkProfile.self)) + let descriptor = FetchDescriptor(predicate: #Predicate { identifiers.contains($0.id) }) + let profiles = try modelContext.fetch(descriptor) + return profiles.map { NetworkProfileEntity(from: $0) } + } + + func suggestedEntities() async throws -> [NetworkProfileEntity] { + let modelContext = ModelContext(try ModelContainer(for: ProfileSummary.self, NetworkProfile.self)) + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) + let profiles = try modelContext.fetch(descriptor) + return profiles.map { NetworkProfileEntity(from: $0) } + } +} + +// MARK: - Helpers + +enum IntentError: Swift.Error, CustomLocalizedStringResourceConvertible { + case noProfileFound + case connectionFailed(String) + + var localizedStringResource: LocalizedStringResource { + switch self { + case .noProfileFound: + return "no_network_profile_found" + case .connectionFailed(let msg): + return "connection_failed \(msg)" + } + } +} + +@MainActor +func getTargetProfile(_ entity: NetworkProfileEntity?) throws -> ProfileSummary { + let container = try ModelContainer(for: ProfileSummary.self, NetworkProfile.self) + let context = ModelContext(container) + + if let entity = entity { + let id = entity.id + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + if let profile = try context.fetch(descriptor).first { + return profile + } + } + + // Fallback: Use first available profile + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.createdAt)]) + if let profile = try context.fetch(descriptor).first { + return profile + } + + throw IntentError.noProfileFound +} + +// MARK: - Intents + +struct ConnectIntent: AppIntent { + static var title: LocalizedStringResource = "connect_easytier" + static var description: IntentDescription = IntentDescription("connect_to_easytier_network") + static var openAppWhenRun: Bool = false + + @Parameter(title: "network") + var network: NetworkProfileEntity? + + @MainActor + func perform() async throws -> some IntentResult { + let profile = try getTargetProfile(network) + let manager = NEManager() + try await manager.load() + try await manager.connect(profile: profile) + return .result() + } +} + +struct DisconnectIntent: AppIntent { + static var title: LocalizedStringResource = "disconnect_easytier" + static var description: IntentDescription = IntentDescription("disconnect_from_easytier_network") + static var openAppWhenRun: Bool = false + + @MainActor + func perform() async throws -> some IntentResult { + let manager = NEManager() + try await manager.load() + await manager.disconnect() + return .result() + } +} + +struct ToggleConnectIntent: AppIntent { + static var title: LocalizedStringResource = "toggle_easytier" + static var description: IntentDescription = IntentDescription("toggle_easytier_network_connection") + static var openAppWhenRun: Bool = false + + @Parameter(title: "network") + var network: NetworkProfileEntity? + + @MainActor + func perform() async throws -> some IntentResult { + let manager = NEManager() + try await manager.load() + + // Check current status + // Note: manager.status might be initial state if not refreshed, but load() should refresh it. + // However, NEManager.load() updates the manager instance which updates status via delegation. + // We might need a small delay or rely on the fact that load() fetches the managers. + + // Since load() calls setManager which sets status, we can check it. + // But manager.status is @Published, so accessing it directly is fine on MainActor. + + if manager.status == .connected || manager.status == .connecting { + await manager.disconnect() + return .result() + } else { + let profile = try getTargetProfile(network) + try await manager.connect(profile: profile) + return .result() + } + } +} + +// MARK: - Shortcuts Provider + +struct EasyTierShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: ConnectIntent(), + phrases: [ + "Connect to \(.applicationName)", + "Start \(.applicationName) VPN", + "Start \(.applicationName)" + ], + shortTitle: "connect_easytier", + systemImageName: "play.circle" + ) + + AppShortcut( + intent: DisconnectIntent(), + phrases: [ + "Disconnect from \(.applicationName)", + "Stop \(.applicationName) VPN", + "Stop \(.applicationName)" + ], + shortTitle: "disconnect_easytier", + systemImageName: "stop.circle" + ) + + AppShortcut( + intent: ToggleConnectIntent(), + phrases: [ + "Toggle \(.applicationName)", + "Switch \(.applicationName)" + ], + shortTitle: "toggle_easytier", + systemImageName: "arrow.triangle.2.circlepath.circle" + ) + } +} From 617eefda2e332cf19800d445aba30ef72c9189f2 Mon Sep 17 00:00:00 2001 From: YinMo19 Date: Tue, 13 Jan 2026 17:22:27 +0800 Subject: [PATCH 3/7] feat: control center icon and control --- .gitignore | 3 +- ControlWidgets/AppIntent.swift | 35 ++++ .../AccentColor.colorset/Contents.json | 11 ++ .../AppIcon.appiconset/Contents.json | 35 ++++ ControlWidgets/Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 ++ ControlWidgets/ControlWidgetsBundle.swift | 16 ++ ControlWidgets/ControlWidgetsControl.swift | 45 +++++ .../ControlWidgetsExtension.entitlements | 10 ++ ControlWidgets/Info.plist | 11 ++ EasyTier.xcodeproj/project.pbxproj | 168 +++++++++++++++++- EasyTier/Utils/NEManager.swift | 12 ++ EasyTier/Views/DashboardView.swift | 32 +++- 13 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 ControlWidgets/AppIntent.swift create mode 100644 ControlWidgets/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ControlWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ControlWidgets/Assets.xcassets/Contents.json create mode 100644 ControlWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 ControlWidgets/ControlWidgetsBundle.swift create mode 100644 ControlWidgets/ControlWidgetsControl.swift create mode 100644 ControlWidgets/ControlWidgetsExtension.entitlements create mode 100644 ControlWidgets/Info.plist diff --git a/.gitignore b/.gitignore index 9699471..d14ccf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ -EasyTier.xcodeproj/project.xcworkspace/xcuserdata/ +xcuserdata +build.log compile_commands.json CLAUDE.md diff --git a/ControlWidgets/AppIntent.swift b/ControlWidgets/AppIntent.swift new file mode 100644 index 0000000..3715667 --- /dev/null +++ b/ControlWidgets/AppIntent.swift @@ -0,0 +1,35 @@ +// +// AppIntent.swift +// ControlWidgets +// +// Created by YinMo19 on 2026/1/13. +// + +import AppIntents + +// Toggle VPN connection via App Group notification +struct ToggleVPNIntent: SetValueIntent { + static let title: LocalizedStringResource = "Toggle VPN" + + @Parameter(title: "Connected") + var value: Bool + + func perform() async throws -> some IntentResult { + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + + // Set the desired state + defaults?.set(value, forKey: "VPNDesiredState") + defaults?.synchronize() + + // Notify main app to perform the action + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName("site.yinmo.easytier.toggleVPN" as CFString), + nil, + nil, + true + ) + + return .result() + } +} \ No newline at end of file diff --git a/ControlWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/ControlWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ControlWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ControlWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/ControlWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/ControlWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ControlWidgets/Assets.xcassets/Contents.json b/ControlWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ControlWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ControlWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ControlWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ControlWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ControlWidgets/ControlWidgetsBundle.swift b/ControlWidgets/ControlWidgetsBundle.swift new file mode 100644 index 0000000..0be103e --- /dev/null +++ b/ControlWidgets/ControlWidgetsBundle.swift @@ -0,0 +1,16 @@ +// +// ControlWidgetsBundle.swift +// ControlWidgets +// +// Created by YinMo19 on 2026/1/13. +// + +import WidgetKit +import SwiftUI + +@main +struct ControlWidgetsBundle: WidgetBundle { + var body: some Widget { + ControlWidgetsControl() + } +} \ No newline at end of file diff --git a/ControlWidgets/ControlWidgetsControl.swift b/ControlWidgets/ControlWidgetsControl.swift new file mode 100644 index 0000000..15996f3 --- /dev/null +++ b/ControlWidgets/ControlWidgetsControl.swift @@ -0,0 +1,45 @@ +// +// ControlWidgetsControl.swift +// ControlWidgets +// +// Created by YinMo19 on 2026/1/13. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct ControlWidgetsControl: ControlWidget { + static let kind: String = "site.yinmo.easytier.ControlWidgets" + + var body: some ControlWidgetConfiguration { + AppIntentControlConfiguration( + kind: Self.kind, + provider: VPNControlProvider() + ) { isConnected in + ControlWidgetToggle( + isOn: isConnected, + action: ToggleVPNIntent() + ) { + Label("EasyTier", systemImage: "network") + } + } + .displayName("EasyTier") + .description("Toggle VPN connection") + } +} + +struct VPNControlProvider: AppIntentControlValueProvider { + func previewValue(configuration: VPNControlConfiguration) -> Bool { + false + } + + func currentValue(configuration: VPNControlConfiguration) async throws -> Bool { + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + return defaults?.bool(forKey: "VPNIsConnected") ?? false + } +} + +struct VPNControlConfiguration: ControlConfigurationIntent { + static let title: LocalizedStringResource = "VPN Control" +} \ No newline at end of file diff --git a/ControlWidgets/ControlWidgetsExtension.entitlements b/ControlWidgets/ControlWidgetsExtension.entitlements new file mode 100644 index 0000000..519fce0 --- /dev/null +++ b/ControlWidgets/ControlWidgetsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.site.yinmo.easytier + + + diff --git a/ControlWidgets/Info.plist b/ControlWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/ControlWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/EasyTier.xcodeproj/project.pbxproj b/EasyTier.xcodeproj/project.pbxproj index 0b42563..ea92a32 100644 --- a/EasyTier.xcodeproj/project.pbxproj +++ b/EasyTier.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 055836392F04D26700139811 /* EasyTierNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 05B61F5C2F05056D00646DDC /* libeasytier_ios.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */; }; 05B6235C2F08098100646DDC /* TOMLKit in Frameworks */ = {isa = PBXBuildFile; productRef = 05B6235B2F08098100646DDC /* TOMLKit */; }; + 7A59B81B2F1641100069728D /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A59B81A2F1641100069728D /* WidgetKit.framework */; }; + 7A59B81D2F1641100069728D /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A59B81C2F1641100069728D /* SwiftUI.framework */; }; + 7A59B82E2F1641110069728D /* ControlWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7A59B8192F1641100069728D /* ControlWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -21,6 +24,13 @@ remoteGlobalIDString = 0558362D2F04D26600139811; remoteInfo = NetworkExtension; }; + 7A59B82C2F1641110069728D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 051C6A032F03AB0000AF29B1 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7A59B8182F1641100069728D; + remoteInfo = ControlWidgetsExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -30,6 +40,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 7A59B82E2F1641110069728D /* ControlWidgetsExtension.appex in Embed Foundation Extensions */, 055836392F04D26700139811 /* EasyTierNetworkExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -42,6 +53,9 @@ 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = EasyTierNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 055836302F04D26700139811 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libeasytier_ios.a; sourceTree = ""; }; + 7A59B8192F1641100069728D /* ControlWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ControlWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 7A59B81A2F1641100069728D /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 7A59B81C2F1641100069728D /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -59,6 +73,13 @@ ); target = 051C6A0A2F03AB0000AF29B1 /* EasyTier */; }; + 7A59B8322F1641110069728D /* Exceptions for "ControlWidgets" folder in "ControlWidgetsExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7A59B8182F1641100069728D /* ControlWidgetsExtension */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -78,6 +99,14 @@ path = EasyTierNetworkExtension; sourceTree = ""; }; + 7A59B81E2F1641100069728D /* ControlWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7A59B8322F1641110069728D /* Exceptions for "ControlWidgets" folder in "ControlWidgetsExtension" target */, + ); + path = ControlWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -98,6 +127,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7A59B8162F1641100069728D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7A59B81D2F1641100069728D /* SwiftUI.framework in Frameworks */, + 7A59B81B2F1641100069728D /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -106,6 +144,7 @@ children = ( 051C6A0D2F03AB0000AF29B1 /* EasyTier */, 055836322F04D26700139811 /* EasyTierNetworkExtension */, + 7A59B81E2F1641100069728D /* ControlWidgets */, 0558362F2F04D26700139811 /* Frameworks */, 051C6A0C2F03AB0000AF29B1 /* Products */, ); @@ -116,6 +155,7 @@ children = ( 051C6A0B2F03AB0000AF29B1 /* EasyTier.app */, 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */, + 7A59B8192F1641100069728D /* ControlWidgetsExtension.appex */, ); name = Products; sourceTree = ""; @@ -125,6 +165,8 @@ children = ( 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */, 055836302F04D26700139811 /* NetworkExtension.framework */, + 7A59B81A2F1641100069728D /* WidgetKit.framework */, + 7A59B81C2F1641100069728D /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -145,6 +187,7 @@ ); dependencies = ( 055836382F04D26700139811 /* PBXTargetDependency */, + 7A59B82D2F1641110069728D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 051C6A0D2F03AB0000AF29B1 /* EasyTier */, @@ -180,6 +223,28 @@ productReference = 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 7A59B8182F1641100069728D /* ControlWidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7A59B8312F1641110069728D /* Build configuration list for PBXNativeTarget "ControlWidgetsExtension" */; + buildPhases = ( + 7A59B8152F1641100069728D /* Sources */, + 7A59B8162F1641100069728D /* Frameworks */, + 7A59B8172F1641100069728D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7A59B81E2F1641100069728D /* ControlWidgets */, + ); + name = ControlWidgetsExtension; + packageProductDependencies = ( + ); + productName = ControlWidgetsExtension; + productReference = 7A59B8192F1641100069728D /* ControlWidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -187,7 +252,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2620; TargetAttributes = { 051C6A0A2F03AB0000AF29B1 = { @@ -196,6 +261,9 @@ 0558362D2F04D26600139811 = { CreatedOnToolsVersion = 26.2; }; + 7A59B8182F1641100069728D = { + CreatedOnToolsVersion = 26.0.1; + }; }; }; buildConfigurationList = 051C6A062F03AB0000AF29B1 /* Build configuration list for PBXProject "EasyTier" */; @@ -217,6 +285,7 @@ targets = ( 051C6A0A2F03AB0000AF29B1 /* EasyTier */, 0558362D2F04D26600139811 /* EasyTierNetworkExtension */, + 7A59B8182F1641100069728D /* ControlWidgetsExtension */, ); }; /* End PBXProject section */ @@ -236,6 +305,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7A59B8172F1641100069728D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -275,6 +351,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7A59B8152F1641100069728D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -283,6 +366,11 @@ target = 0558362D2F04D26600139811 /* EasyTierNetworkExtension */; targetProxy = 055836372F04D26700139811 /* PBXContainerItemProxy */; }; + 7A59B82D2F1641110069728D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7A59B8182F1641100069728D /* ControlWidgetsExtension */; + targetProxy = 7A59B82C2F1641110069728D /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -615,6 +703,75 @@ }; name = Release; }; + 7A59B82F2F1641110069728D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = ControlWidgets/ControlWidgetsExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Y8RH943F65; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ControlWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ControlWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = site.yinmo.easytier.controlwidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7A59B8302F1641110069728D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = ControlWidgets/ControlWidgetsExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Y8RH943F65; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ControlWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ControlWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = site.yinmo.easytier.controlwidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -645,6 +802,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7A59B8312F1641110069728D /* Build configuration list for PBXNativeTarget "ControlWidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7A59B82F2F1641110069728D /* Debug */, + 7A59B8302F1641110069728D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/EasyTier/Utils/NEManager.swift b/EasyTier/Utils/NEManager.swift index f17536f..dee4e98 100644 --- a/EasyTier/Utils/NEManager.swift +++ b/EasyTier/Utils/NEManager.swift @@ -1,6 +1,7 @@ import Foundation import Combine import NetworkExtension +import WidgetKit import TOMLKit import os @@ -81,6 +82,17 @@ class NEManager: NEManagerProtocol { if self.status == .invalid { self.manager = nil } + + // Sync VPN connection status to App Group for Control Widget + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + let isConnected = self.status == .connected + defaults?.set(isConnected, forKey: "VPNIsConnected") + defaults?.synchronize() + + // Reload Control Center Widget to reflect new state + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadControls(ofKind: "site.yinmo.easytier.ControlWidgets") + } } } } diff --git a/EasyTier/Views/DashboardView.swift b/EasyTier/Views/DashboardView.swift index 82863a4..7223335 100644 --- a/EasyTier/Views/DashboardView.swift +++ b/EasyTier/Views/DashboardView.swift @@ -30,6 +30,7 @@ struct DashboardView: View { @State var errorMessage: TextItem? @State private var darwinObserver: DNObserver? = nil + @State private var widgetToggleObserver: DNObserver? = nil var selectedProfile: ProfileSummary? { guard let selectedProfileId else { return nil } @@ -237,6 +238,34 @@ struct DashboardView: View { } } } + + // Register Darwin notification observer for Control Widget toggle + widgetToggleObserver = DNObserver(name: "site.yinmo.easytier.toggleVPN") { + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + let desiredState = defaults?.bool(forKey: "VPNDesiredState") ?? false + + DispatchQueue.main.async { + if desiredState { + // Connect using selected profile or first available + guard let profile = self.selectedProfile ?? self.networks.first else { + DashboardLogger.warning("No profile available for widget toggle") + return + } + Task { + do { + try await self.manager.connect(profile: profile) + } catch { + DashboardLogger.error("Widget toggle connect failed: \(error.localizedDescription)") + } + } + } else { + // Disconnect + Task { + await self.manager.disconnect() + } + } + } + } } .onChange(of: selectedProfile) { lastSelected = selectedProfile?.id.uuidString @@ -246,8 +275,9 @@ struct DashboardView: View { } } .onDisappear { - // Release observer to remove registration + // Release observers to remove registration darwinObserver = nil + widgetToggleObserver = nil } .sheet(isPresented: $showManageSheet) { sheetView From c2683efe431027d55df963bdc34fcdf9b960de60 Mon Sep 17 00:00:00 2001 From: YinMo19 Date: Tue, 13 Jan 2026 18:25:19 +0800 Subject: [PATCH 4/7] feat: sync the widget and app. ref sing-box. --- ControlWidgets/AppIntent.swift | 35 ---------- ControlWidgets/ControlWidgetsControl.swift | 70 +++++++++++++++---- .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 -- .../xcschemes/xcschememanagement.plist | 32 --------- EasyTier/Utils/NEManager.swift | 35 +++++++--- EasyTier/Views/DashboardView.swift | 32 +-------- 6 files changed, 82 insertions(+), 128 deletions(-) delete mode 100644 ControlWidgets/AppIntent.swift delete mode 100644 EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist delete mode 100644 EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/ControlWidgets/AppIntent.swift b/ControlWidgets/AppIntent.swift deleted file mode 100644 index 3715667..0000000 --- a/ControlWidgets/AppIntent.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// AppIntent.swift -// ControlWidgets -// -// Created by YinMo19 on 2026/1/13. -// - -import AppIntents - -// Toggle VPN connection via App Group notification -struct ToggleVPNIntent: SetValueIntent { - static let title: LocalizedStringResource = "Toggle VPN" - - @Parameter(title: "Connected") - var value: Bool - - func perform() async throws -> some IntentResult { - let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") - - // Set the desired state - defaults?.set(value, forKey: "VPNDesiredState") - defaults?.synchronize() - - // Notify main app to perform the action - CFNotificationCenterPostNotification( - CFNotificationCenterGetDarwinNotifyCenter(), - CFNotificationName("site.yinmo.easytier.toggleVPN" as CFString), - nil, - nil, - true - ) - - return .result() - } -} \ No newline at end of file diff --git a/ControlWidgets/ControlWidgetsControl.swift b/ControlWidgets/ControlWidgetsControl.swift index 15996f3..22ae2a3 100644 --- a/ControlWidgets/ControlWidgetsControl.swift +++ b/ControlWidgets/ControlWidgetsControl.swift @@ -8,20 +8,23 @@ import AppIntents import SwiftUI import WidgetKit +import NetworkExtension struct ControlWidgetsControl: ControlWidget { - static let kind: String = "site.yinmo.easytier.ControlWidgets" + static let kind: String = "site.yinmo.easytier.controlwidgets" var body: some ControlWidgetConfiguration { - AppIntentControlConfiguration( + StaticControlConfiguration( kind: Self.kind, provider: VPNControlProvider() ) { isConnected in ControlWidgetToggle( + "EasyTier", isOn: isConnected, action: ToggleVPNIntent() - ) { - Label("EasyTier", systemImage: "network") + ) { isOn in + Label(isOn ? "Connected" : "Disconnected", systemImage: "network") + .controlWidgetActionHint(isOn ? "Disconnect" : "Connect") } } .displayName("EasyTier") @@ -29,17 +32,54 @@ struct ControlWidgetsControl: ControlWidget { } } -struct VPNControlProvider: AppIntentControlValueProvider { - func previewValue(configuration: VPNControlConfiguration) -> Bool { - false - } - - func currentValue(configuration: VPNControlConfiguration) async throws -> Bool { - let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") - return defaults?.bool(forKey: "VPNIsConnected") ?? false +extension ControlWidgetsControl { + struct VPNControlProvider: ControlValueProvider { + var previewValue: Bool { + false + } + + func currentValue() async throws -> Bool { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first else { + return false + } + return manager.connection.status == .connected + } } } -struct VPNControlConfiguration: ControlConfigurationIntent { - static let title: LocalizedStringResource = "VPN Control" -} \ No newline at end of file +struct ToggleVPNIntent: SetValueIntent { + static let title: LocalizedStringResource = "Toggle VPN" + + @Parameter(title: "Connected") + var value: Bool + + func perform() async throws -> some IntentResult { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first else { + return .result() + } + + if value { + // Connect - need to load config from App Group + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + guard let configData = defaults?.data(forKey: "LastVPNConfig"), + let config = try? JSONDecoder().decode([String: String].self, from: configData) else { + // Try to start with empty options as fallback + try manager.connection.startVPNTunnel() + return .result() + } + + // Convert to NSDictionary for VPN options + var options: [String: NSObject] = [:] + for (key, val) in config { + options[key] = val as NSString + } + try manager.connection.startVPNTunnel(options: options) + } else { + manager.connection.stopVPNTunnel() + } + + return .result() + } +} diff --git a/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist deleted file mode 100644 index 948623e..0000000 --- a/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcschemes/xcschememanagement.plist b/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 69341d8..0000000 --- a/EasyTier.xcodeproj/xcuserdata/chenx.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - SchemeUserState - - EasyTier.xcscheme_^#shared#^_ - - orderHint - 0 - - EasyTierNetworkExtension.xcscheme_^#shared#^_ - - orderHint - 1 - - - SuppressBuildableAutocreation - - 051C6A0A2F03AB0000AF29B1 - - primary - - - 0558362D2F04D26600139811 - - primary - - - - - diff --git a/EasyTier/Utils/NEManager.swift b/EasyTier/Utils/NEManager.swift index dee4e98..8c97838 100644 --- a/EasyTier/Utils/NEManager.swift +++ b/EasyTier/Utils/NEManager.swift @@ -84,19 +84,18 @@ class NEManager: NEManagerProtocol { } // Sync VPN connection status to App Group for Control Widget - let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") - let isConnected = self.status == .connected - defaults?.set(isConnected, forKey: "VPNIsConnected") - defaults?.synchronize() - - // Reload Control Center Widget to reflect new state - if #available(iOS 18.0, *) { - ControlCenter.shared.reloadControls(ofKind: "site.yinmo.easytier.ControlWidgets") - } + self.syncWidgetState() } } } + // Notify Control Widget to refresh its state + private func syncWidgetState() { + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadControls(ofKind: "site.yinmo.easytier.controlwidgets") + } + } + private func reset() { manager = nil connection = nil @@ -211,6 +210,20 @@ class NEManager: NEManagerProtocol { options["dns"] = dnsArray as NSArray } + // Save config to App Group for Widget use + let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") + var configDict: [String: String] = [:] + for (key, value) in options { + if let strValue = value as? String { + configDict[key] = strValue + } + } + if let configData = try? JSONEncoder().encode(configDict) { + defaults?.set(configData, forKey: "LastVPNConfig") + defaults?.synchronize() + } + + do { try manager.connection.startVPNTunnel(options: options) } catch { @@ -218,6 +231,8 @@ class NEManager: NEManagerProtocol { throw error } Self.logger.info("connect() started") + // Immediately sync widget state after initiating connection + syncWidgetState() } func disconnect() async { @@ -226,6 +241,8 @@ class NEManager: NEManagerProtocol { return } manager.connection.stopVPNTunnel() + // Immediately sync widget state after initiating disconnection + syncWidgetState() } func updateName(name: String, server: String) async { diff --git a/EasyTier/Views/DashboardView.swift b/EasyTier/Views/DashboardView.swift index 7223335..82863a4 100644 --- a/EasyTier/Views/DashboardView.swift +++ b/EasyTier/Views/DashboardView.swift @@ -30,7 +30,6 @@ struct DashboardView: View { @State var errorMessage: TextItem? @State private var darwinObserver: DNObserver? = nil - @State private var widgetToggleObserver: DNObserver? = nil var selectedProfile: ProfileSummary? { guard let selectedProfileId else { return nil } @@ -238,34 +237,6 @@ struct DashboardView: View { } } } - - // Register Darwin notification observer for Control Widget toggle - widgetToggleObserver = DNObserver(name: "site.yinmo.easytier.toggleVPN") { - let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") - let desiredState = defaults?.bool(forKey: "VPNDesiredState") ?? false - - DispatchQueue.main.async { - if desiredState { - // Connect using selected profile or first available - guard let profile = self.selectedProfile ?? self.networks.first else { - DashboardLogger.warning("No profile available for widget toggle") - return - } - Task { - do { - try await self.manager.connect(profile: profile) - } catch { - DashboardLogger.error("Widget toggle connect failed: \(error.localizedDescription)") - } - } - } else { - // Disconnect - Task { - await self.manager.disconnect() - } - } - } - } } .onChange(of: selectedProfile) { lastSelected = selectedProfile?.id.uuidString @@ -275,9 +246,8 @@ struct DashboardView: View { } } .onDisappear { - // Release observers to remove registration + // Release observer to remove registration darwinObserver = nil - widgetToggleObserver = nil } .sheet(isPresented: $showManageSheet) { sheetView From 0f00b4ee23313656c8ce86719d6ea8bb78f5e640 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Jan 2026 05:16:31 +0000 Subject: [PATCH 5/7] feat: add i18n support for Control Widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added localization keys to Localizable.xcstrings: - vpn_connected (已连接) - vpn_disconnected (已断开) - vpn_connect (连接) - vpn_disconnect (断开) - toggle_vpn (切换 VPN) - toggle_vpn_connection (切换 VPN 连接) - Updated ControlWidgetsControl.swift to use LocalizedStringResource Co-authored-by: chenx-dust <16610294+chenx-dust@users.noreply.github.com> --- ControlWidgets/ControlWidgetsControl.swift | 10 +-- EasyTier/Localizable.xcstrings | 96 ++++++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/ControlWidgets/ControlWidgetsControl.swift b/ControlWidgets/ControlWidgetsControl.swift index 22ae2a3..d38b11e 100644 --- a/ControlWidgets/ControlWidgetsControl.swift +++ b/ControlWidgets/ControlWidgetsControl.swift @@ -23,12 +23,12 @@ struct ControlWidgetsControl: ControlWidget { isOn: isConnected, action: ToggleVPNIntent() ) { isOn in - Label(isOn ? "Connected" : "Disconnected", systemImage: "network") - .controlWidgetActionHint(isOn ? "Disconnect" : "Connect") + Label(isOn ? LocalizedStringResource("vpn_connected") : LocalizedStringResource("vpn_disconnected"), systemImage: "network") + .controlWidgetActionHint(isOn ? LocalizedStringResource("vpn_disconnect") : LocalizedStringResource("vpn_connect")) } } .displayName("EasyTier") - .description("Toggle VPN connection") + .description(LocalizedStringResource("toggle_vpn_connection")) } } @@ -49,9 +49,9 @@ extension ControlWidgetsControl { } struct ToggleVPNIntent: SetValueIntent { - static let title: LocalizedStringResource = "Toggle VPN" + static let title: LocalizedStringResource = "toggle_vpn" - @Parameter(title: "Connected") + @Parameter(title: LocalizedStringResource("vpn_connected")) var value: Bool func perform() async throws -> some IntentResult { diff --git a/EasyTier/Localizable.xcstrings b/EasyTier/Localizable.xcstrings index ecfc4a9..28d4b78 100644 --- a/EasyTier/Localizable.xcstrings +++ b/EasyTier/Localizable.xcstrings @@ -3241,6 +3241,38 @@ } } }, + "toggle_vpn" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle VPN" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换 VPN" + } + } + } + }, + "toggle_vpn_connection" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toggle VPN connection" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "切换 VPN 连接" + } + } + } + }, "tunnel_proto" : { "localizations" : { "en" : { @@ -3465,6 +3497,70 @@ } } }, + "vpn_connect" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接" + } + } + } + }, + "vpn_connected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已连接" + } + } + } + }, + "vpn_disconnect" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开" + } + } + } + }, + "vpn_disconnected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnected" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已断开" + } + } + } + }, "vpn_portal_client_network" : { "localizations" : { "en" : { From 9c1866faa8c13901ee91bdcbe09c633029a27f80 Mon Sep 17 00:00:00 2001 From: Chenx Dust Date: Wed, 14 Jan 2026 14:19:45 +0800 Subject: [PATCH 6/7] refactor: better option control --- ControlWidgets/ControlWidgetsBundle.swift | 9 +--- ControlWidgets/ControlWidgetsControl.swift | 17 ++----- EasyTier.xcodeproj/project.pbxproj | 14 +++-- EasyTier/Utils/NEManager.swift | 51 +++++++++++-------- .../Shortcuts.swift} | 0 EasyTier/Views/DashboardView.swift | 6 +++ ...lizable.xcstrings => Localizable.xcstrings | 0 7 files changed, 51 insertions(+), 46 deletions(-) rename EasyTier/{Shortcuts/EasyTierShortcuts.swift => Utils/Shortcuts.swift} (100%) rename EasyTier/Localizable.xcstrings => Localizable.xcstrings (100%) diff --git a/ControlWidgets/ControlWidgetsBundle.swift b/ControlWidgets/ControlWidgetsBundle.swift index 0be103e..4156a3f 100644 --- a/ControlWidgets/ControlWidgetsBundle.swift +++ b/ControlWidgets/ControlWidgetsBundle.swift @@ -1,10 +1,3 @@ -// -// ControlWidgetsBundle.swift -// ControlWidgets -// -// Created by YinMo19 on 2026/1/13. -// - import WidgetKit import SwiftUI @@ -13,4 +6,4 @@ struct ControlWidgetsBundle: WidgetBundle { var body: some Widget { ControlWidgetsControl() } -} \ No newline at end of file +} diff --git a/ControlWidgets/ControlWidgetsControl.swift b/ControlWidgets/ControlWidgetsControl.swift index d38b11e..f44fd24 100644 --- a/ControlWidgets/ControlWidgetsControl.swift +++ b/ControlWidgets/ControlWidgetsControl.swift @@ -1,10 +1,3 @@ -// -// ControlWidgetsControl.swift -// ControlWidgets -// -// Created by YinMo19 on 2026/1/13. -// - import AppIntents import SwiftUI import WidgetKit @@ -23,12 +16,12 @@ struct ControlWidgetsControl: ControlWidget { isOn: isConnected, action: ToggleVPNIntent() ) { isOn in - Label(isOn ? LocalizedStringResource("vpn_connected") : LocalizedStringResource("vpn_disconnected"), systemImage: "network") - .controlWidgetActionHint(isOn ? LocalizedStringResource("vpn_disconnect") : LocalizedStringResource("vpn_connect")) + Label(isOn ? "vpn_connected" : "vpn_disconnected", systemImage: "network") + .controlWidgetActionHint(isOn ? "vpn_disconnect" : "vpn_connect") } } .displayName("EasyTier") - .description(LocalizedStringResource("toggle_vpn_connection")) + .description("toggle_vpn_connection") } } @@ -43,7 +36,7 @@ extension ControlWidgetsControl { guard let manager = managers.first else { return false } - return manager.connection.status == .connected + return [.connecting, .connected, .reasserting].contains(manager.connection.status) } } } @@ -51,7 +44,7 @@ extension ControlWidgetsControl { struct ToggleVPNIntent: SetValueIntent { static let title: LocalizedStringResource = "toggle_vpn" - @Parameter(title: LocalizedStringResource("vpn_connected")) + @Parameter(title: "vpn_connected") var value: Bool func perform() async throws -> some IntentResult { diff --git a/EasyTier.xcodeproj/project.pbxproj b/EasyTier.xcodeproj/project.pbxproj index ea92a32..84635e6 100644 --- a/EasyTier.xcodeproj/project.pbxproj +++ b/EasyTier.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 0526D7992F1762C300968CE2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 0526D7822F1762C300968CE2 /* Localizable.xcstrings */; }; + 0526D7B32F17656B00968CE2 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 0526D7822F1762C300968CE2 /* Localizable.xcstrings */; }; 055836312F04D26700139811 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 055836302F04D26700139811 /* NetworkExtension.framework */; }; 055836392F04D26700139811 /* EasyTierNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 05B61F5C2F05056D00646DDC /* libeasytier_ios.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */; }; @@ -50,6 +52,7 @@ /* Begin PBXFileReference section */ 051C6A0B2F03AB0000AF29B1 /* EasyTier.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EasyTier.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0526D7822F1762C300968CE2 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = EasyTierNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 055836302F04D26700139811 /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; }; 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libeasytier_ios.a; sourceTree = ""; }; @@ -145,6 +148,7 @@ 051C6A0D2F03AB0000AF29B1 /* EasyTier */, 055836322F04D26700139811 /* EasyTierNetworkExtension */, 7A59B81E2F1641100069728D /* ControlWidgets */, + 0526D7822F1762C300968CE2 /* Localizable.xcstrings */, 0558362F2F04D26700139811 /* Frameworks */, 051C6A0C2F03AB0000AF29B1 /* Products */, ); @@ -295,6 +299,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0526D7992F1762C300968CE2 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -309,6 +314,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0526D7B32F17656B00968CE2 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -711,11 +717,11 @@ CODE_SIGN_ENTITLEMENTS = ControlWidgets/ControlWidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = Y8RH943F65; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ControlWidgets/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ControlWidgets; + INFOPLIST_KEY_CFBundleDisplayName = EasyTierControlWidgets; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -745,11 +751,11 @@ CODE_SIGN_ENTITLEMENTS = ControlWidgets/ControlWidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 14; DEVELOPMENT_TEAM = Y8RH943F65; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = ControlWidgets/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = ControlWidgets; + INFOPLIST_KEY_CFBundleDisplayName = EasyTierControlWidgets; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/EasyTier/Utils/NEManager.swift b/EasyTier/Utils/NEManager.swift index 8c97838..b690c49 100644 --- a/EasyTier/Utils/NEManager.swift +++ b/EasyTier/Utils/NEManager.swift @@ -154,26 +154,7 @@ class NEManager: NEManagerProtocol { } } - func connect(profile: ProfileSummary) async throws { - guard ![.connecting, .connected, .disconnecting, .reasserting].contains(status) else { - Self.logger.warning("connect() failed: in \(String(describing: self.status)) status") - return - } - guard !isLoading else { - Self.logger.warning("connect() failed: not loaded") - return - } - if status == .invalid { - _ = try await NEManager.install() - try await load() - } - guard let manager else { - Self.logger.error("connect() failed: manager is nil") - return - } - manager.isEnabled = true - try await manager.saveToPreferences() - + static func generateOptions(_ profile: ProfileSummary) throws -> [String : NSObject] { var options: [String : NSObject] = [:] let config = NetworkConfig(from: profile.profile, name: profile.name) @@ -210,6 +191,10 @@ class NEManager: NEManagerProtocol { options["dns"] = dnsArray as NSArray } + return options + } + + static func saveOptions(_ options: [String : NSObject]) { // Save config to App Group for Widget use let defaults = UserDefaults(suiteName: "group.site.yinmo.easytier") var configDict: [String: String] = [:] @@ -222,9 +207,31 @@ class NEManager: NEManagerProtocol { defaults?.set(configData, forKey: "LastVPNConfig") defaults?.synchronize() } - - + } + + func connect(profile: ProfileSummary) async throws { + guard ![.connecting, .connected, .disconnecting, .reasserting].contains(status) else { + Self.logger.warning("connect() failed: in \(String(describing: self.status)) status") + return + } + guard !isLoading else { + Self.logger.warning("connect() failed: not loaded") + return + } + if status == .invalid { + _ = try await NEManager.install() + try await load() + } + guard let manager else { + Self.logger.error("connect() failed: manager is nil") + return + } + manager.isEnabled = true + try await manager.saveToPreferences() + do { + let options = try Self.generateOptions(profile) + Self.saveOptions(options) try manager.connection.startVPNTunnel(options: options) } catch { Self.logger.error("connect() start vpn tunnel failed: \(String(describing: error))") diff --git a/EasyTier/Shortcuts/EasyTierShortcuts.swift b/EasyTier/Utils/Shortcuts.swift similarity index 100% rename from EasyTier/Shortcuts/EasyTierShortcuts.swift rename to EasyTier/Utils/Shortcuts.swift diff --git a/EasyTier/Views/DashboardView.swift b/EasyTier/Views/DashboardView.swift index 82863a4..afd7817 100644 --- a/EasyTier/Views/DashboardView.swift +++ b/EasyTier/Views/DashboardView.swift @@ -243,11 +243,17 @@ struct DashboardView: View { guard let selectedProfile else { return } Task { await manager.updateName(name: selectedProfile.name, server: selectedProfile.id.uuidString) + if let options = try? NEManager.generateOptions(selectedProfile) { + NEManager.saveOptions(options) + } } } .onDisappear { // Release observer to remove registration darwinObserver = nil + if let selectedProfile, let options = try? NEManager.generateOptions(selectedProfile) { + NEManager.saveOptions(options) + } } .sheet(isPresented: $showManageSheet) { sheetView diff --git a/EasyTier/Localizable.xcstrings b/Localizable.xcstrings similarity index 100% rename from EasyTier/Localizable.xcstrings rename to Localizable.xcstrings From a915ced9ef2652bb9555bb7c8448635fae096560 Mon Sep 17 00:00:00 2001 From: Chenx Dust Date: Wed, 14 Jan 2026 14:46:25 +0800 Subject: [PATCH 7/7] chore: brighter icon --- EasyTier/AppIcon.icon/icon.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EasyTier/AppIcon.icon/icon.json b/EasyTier/AppIcon.icon/icon.json index 218f788..bc32348 100644 --- a/EasyTier/AppIcon.icon/icon.json +++ b/EasyTier/AppIcon.icon/icon.json @@ -6,7 +6,7 @@ { "blend-mode" : "normal", "fill" : { - "automatic-gradient" : "display-p3:0.02547,0.20504,0.84994,1.00000" + "automatic-gradient" : "display-p3:0.09798,0.35082,0.97266,1.00000" }, "glass" : true, "image-name" : "icon.png",