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" : {