diff --git a/.gitignore b/.gitignore index b5ed3ab..d14ccf2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -EasyTier.xcodeproj/project.xcworkspace/xcuserdata/ +xcuserdata +build.log +compile_commands.json + +CLAUDE.md 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..4156a3f --- /dev/null +++ b/ControlWidgets/ControlWidgetsBundle.swift @@ -0,0 +1,9 @@ +import WidgetKit +import SwiftUI + +@main +struct ControlWidgetsBundle: WidgetBundle { + var body: some Widget { + ControlWidgetsControl() + } +} diff --git a/ControlWidgets/ControlWidgetsControl.swift b/ControlWidgets/ControlWidgetsControl.swift new file mode 100644 index 0000000..f44fd24 --- /dev/null +++ b/ControlWidgets/ControlWidgetsControl.swift @@ -0,0 +1,78 @@ +import AppIntents +import SwiftUI +import WidgetKit +import NetworkExtension + +struct ControlWidgetsControl: ControlWidget { + static let kind: String = "site.yinmo.easytier.controlwidgets" + + var body: some ControlWidgetConfiguration { + StaticControlConfiguration( + kind: Self.kind, + provider: VPNControlProvider() + ) { isConnected in + ControlWidgetToggle( + "EasyTier", + isOn: isConnected, + action: ToggleVPNIntent() + ) { isOn in + Label(isOn ? "vpn_connected" : "vpn_disconnected", systemImage: "network") + .controlWidgetActionHint(isOn ? "vpn_disconnect" : "vpn_connect") + } + } + .displayName("EasyTier") + .description("toggle_vpn_connection") + } +} + +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 [.connecting, .connected, .reasserting].contains(manager.connection.status) + } + } +} + +struct ToggleVPNIntent: SetValueIntent { + static let title: LocalizedStringResource = "toggle_vpn" + + @Parameter(title: "vpn_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/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..84635e6 100644 --- a/EasyTier.xcodeproj/project.pbxproj +++ b/EasyTier.xcodeproj/project.pbxproj @@ -7,10 +7,15 @@ 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 */; }; 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 +26,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 +42,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 7A59B82E2F1641110069728D /* ControlWidgetsExtension.appex in Embed Foundation Extensions */, 055836392F04D26700139811 /* EasyTierNetworkExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -39,9 +52,13 @@ /* 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 = ""; }; + 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 +76,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 +102,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 +130,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 +147,8 @@ children = ( 051C6A0D2F03AB0000AF29B1 /* EasyTier */, 055836322F04D26700139811 /* EasyTierNetworkExtension */, + 7A59B81E2F1641100069728D /* ControlWidgets */, + 0526D7822F1762C300968CE2 /* Localizable.xcstrings */, 0558362F2F04D26700139811 /* Frameworks */, 051C6A0C2F03AB0000AF29B1 /* Products */, ); @@ -116,6 +159,7 @@ children = ( 051C6A0B2F03AB0000AF29B1 /* EasyTier.app */, 0558362E2F04D26600139811 /* EasyTierNetworkExtension.appex */, + 7A59B8192F1641100069728D /* ControlWidgetsExtension.appex */, ); name = Products; sourceTree = ""; @@ -125,6 +169,8 @@ children = ( 05B61F5B2F05056D00646DDC /* libeasytier_ios.a */, 055836302F04D26700139811 /* NetworkExtension.framework */, + 7A59B81A2F1641100069728D /* WidgetKit.framework */, + 7A59B81C2F1641100069728D /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -145,6 +191,7 @@ ); dependencies = ( 055836382F04D26700139811 /* PBXTargetDependency */, + 7A59B82D2F1641110069728D /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 051C6A0D2F03AB0000AF29B1 /* EasyTier */, @@ -180,6 +227,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 +256,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 2620; TargetAttributes = { 051C6A0A2F03AB0000AF29B1 = { @@ -196,6 +265,9 @@ 0558362D2F04D26600139811 = { CreatedOnToolsVersion = 26.2; }; + 7A59B8182F1641100069728D = { + CreatedOnToolsVersion = 26.0.1; + }; }; }; buildConfigurationList = 051C6A062F03AB0000AF29B1 /* Build configuration list for PBXProject "EasyTier" */; @@ -217,6 +289,7 @@ targets = ( 051C6A0A2F03AB0000AF29B1 /* EasyTier */, 0558362D2F04D26600139811 /* EasyTierNetworkExtension */, + 7A59B8182F1641100069728D /* ControlWidgetsExtension */, ); }; /* End PBXProject section */ @@ -226,6 +299,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0526D7992F1762C300968CE2 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -236,6 +310,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7A59B8172F1641100069728D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0526D7B32F17656B00968CE2 /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -275,6 +357,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7A59B8152F1641100069728D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -283,6 +372,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 +709,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 = 14; + DEVELOPMENT_TEAM = Y8RH943F65; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ControlWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = EasyTierControlWidgets; + 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 = 14; + DEVELOPMENT_TEAM = Y8RH943F65; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = ControlWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = EasyTierControlWidgets; + 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 +808,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.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/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", diff --git a/EasyTier/Utils/NEManager.swift b/EasyTier/Utils/NEManager.swift index f17536f..b690c49 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,10 +82,20 @@ class NEManager: NEManagerProtocol { if self.status == .invalid { self.manager = nil } + + // Sync VPN connection status to App Group for Control Widget + 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 @@ -143,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) @@ -199,13 +191,55 @@ 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] = [:] + 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() + } + } + + 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))") throw error } Self.logger.info("connect() started") + // Immediately sync widget state after initiating connection + syncWidgetState() } func disconnect() async { @@ -214,6 +248,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/Utils/Shortcuts.swift b/EasyTier/Utils/Shortcuts.swift new file mode 100644 index 0000000..01178ad --- /dev/null +++ b/EasyTier/Utils/Shortcuts.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" + ) + } +} 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 94% rename from EasyTier/Localizable.xcstrings rename to Localizable.xcstrings index 80e0432..28d4b78 100644 --- a/EasyTier/Localizable.xcstrings +++ b/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,70 @@ } } }, + "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 网络连接" + } + } + } + }, + "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" : { @@ -3318,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" : {