├── .gitignore ├── ExportOptions.plist ├── LICENSE ├── README.md ├── ShutdownScheduler.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcuserdata │ └── shrek.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── ShutdownScheduler ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── Contents.json │ ├── Contents.json │ └── icon_white.imageset │ │ ├── Contents.json │ │ ├── close_icon_16.png │ │ ├── close_icon_32.png │ │ └── close_icon_48.png ├── ContentView.swift ├── Item.swift ├── SettingsManager.swift ├── SettingsWindow.xib ├── SettingsWindowController.swift ├── ShutdownScheduler.entitlements ├── ShutdownSchedulerApp.swift ├── en.lproj │ └── Localizable.strings ├── main.swift └── zh-Hans.lproj │ └── Localizable.strings ├── background.png └── build_shutdown_scheduler.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore 模板适用于Swift/macOS项目 4 | # 5 | # 构建产物 6 | build/ 7 | DerivedData/ 8 | *.dmg 9 | 10 | # Xcode 自动生成的文件 11 | *.moved-aside 12 | *.xccheckout 13 | *.xcscmblueprint 14 | *.xcuserstate 15 | xcuserdata/ 16 | *.xcworkspace 17 | 18 | # Swift 包管理器 19 | .build/ 20 | .swiftpm/ 21 | 22 | # macOS 系统文件 23 | .DS_Store 24 | .AppleDouble 25 | .LSOverride 26 | ._* 27 | 28 | # 缩略图 29 | ._* 30 | 31 | # 可能出现在卷根目录中的文件 32 | .DocumentRevisions-V100 33 | .fseventsd 34 | .Spotlight-V100 35 | .TemporaryItems 36 | .Trashes 37 | .VolumeIcon.icns 38 | .com.apple.timemachine.donotpresent 39 | 40 | # 可能在远程AFP共享上创建的目录 41 | .AppleDB 42 | .AppleDesktop 43 | Network Trash Folder 44 | Temporary Items 45 | .apdisk 46 | 47 | # 其他常见的忽略文件 48 | *.log 49 | *.swp 50 | *.swo 51 | -------------------------------------------------------------------------------- /ExportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | destination 7 | export 8 | method 9 | developer-id 10 | signingStyle 11 | manual 12 | signingCertificate 13 | Developer ID Application: Hangzhou Gravity Cyberinfo Co.,Ltd (6X2HSWDZCR) 14 | teamID 15 | 6X2HSWDZCR 16 | stripSwiftSymbols 17 | 18 | compileBitcode 19 | 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Hu Gang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShutdownScheduler 2 | 3 | ![platform](https://img.shields.io/badge/platform-macOS-blue) 4 | ![language](https://img.shields.io/badge/language-Swift-orange) 5 | ![version](https://img.shields.io/badge/version-1.0-green) 6 | ![license](https://img.shields.io/badge/license-MIT-blue) 7 | 8 | --- 9 | 10 | ## 🇺🇸 English 11 | 12 | **ShutdownScheduler** is a lightweight macOS menu bar app that allows you to schedule automatic shutdown or sleep after a set countdown. 13 | 14 | ### 🧲 Manual Download 15 | 16 | [⬇️ Download Latest Release](https://github.com/ihugang/ShutdownScheduler/releases/latest) 17 | 18 | ### 🍺 Install via Homebrew 19 | 20 | You can install ShutdownScheduler using Homebrew: 21 | 22 | ```bash 23 | brew tap ihugang/shutdownscheduler 24 | brew install --cask shutdownscheduler 25 | ``` 26 | 27 | ### 🔧 Features 28 | - ⏱ Set a countdown in minutes 29 | - 💻 Shutdown or sleep your Mac automatically 30 | - 🔐 Secure execution with administrator privileges 31 | - 📋 Real-time countdown display 32 | - 🪵 Command log viewer 33 | 34 | ### 🚀 How to Use 35 | 1. Launch the app. 36 | 2. Choose delay time and action (shutdown or sleep). 37 | 3. Confirm and let the app handle the rest. 38 | 39 | > 🛡️ Requires admin privileges for system-level actions. 40 | 41 | --- 42 | 43 | ## 🇨🇳 中文说明 44 | 45 | **ShutdownScheduler** 是一款轻量级的 macOS 菜单栏工具,支持在设定倒计时后自动关机或休眠。 46 | 47 | ### 🧲 手工下载安装 48 | 49 | [⬇️ 点击下载最新版](https://github.com/ihugang/ShutdownScheduler/releases/latest) 50 | 51 | ### 🍺 Install via Homebrew 安装说明 52 | 53 | You can install ShutdownScheduler using Homebrew: 54 | 55 | ```bash 56 | brew tap ihugang/shutdownscheduler 57 | brew install --cask shutdownscheduler 58 | ``` 59 | 60 | ### 🔧 功能特点 61 | - ⏱ 设置分钟倒计时 62 | - 💻 自动关机或休眠 63 | - 🔐 使用管理员权限安全执行系统命令 64 | - 📋 显示实时倒计时 65 | - 🪵 命令日志查看 66 | 67 | ### 🚀 使用方法 68 | 1. 启动 App。 69 | 2. 选择延时分钟数和操作类型(关机或休眠)。 70 | 3. 确认后自动执行。 71 | 72 | > 🛡️ 第一次使用需要输入管理员密码授权。 73 | 74 | --- 75 | 76 | ## 📄 License 77 | 78 | This project is licensed under the **MIT License**. 79 | See the [LICENSE](LICENSE) file for details. 80 | 81 | --- 82 | 🎯 Built with SwiftUI | 🍎 macOS only -------------------------------------------------------------------------------- /ShutdownScheduler.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 14EA50F62DCC7AA60096E5C2 /* build_shutdown_scheduler.sh in Resources */ = {isa = PBXBuildFile; fileRef = 14EA50F42DCC7AA20096E5C2 /* build_shutdown_scheduler.sh */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXFileReference section */ 14 | 14DAF8622DCB33D60066AE2E /* ShutdownScheduler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ShutdownScheduler.app; sourceTree = BUILT_PRODUCTS_DIR; }; 15 | 14EA50F42DCC7AA20096E5C2 /* build_shutdown_scheduler.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build_shutdown_scheduler.sh; sourceTree = ""; }; 16 | /* End PBXFileReference section */ 17 | 18 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 19 | 14DAF8642DCB33D60066AE2E /* ShutdownScheduler */ = { 20 | isa = PBXFileSystemSynchronizedRootGroup; 21 | path = ShutdownScheduler; 22 | sourceTree = ""; 23 | }; 24 | /* End PBXFileSystemSynchronizedRootGroup section */ 25 | 26 | /* Begin PBXFrameworksBuildPhase section */ 27 | 14DAF85F2DCB33D60066AE2E /* Frameworks */ = { 28 | isa = PBXFrameworksBuildPhase; 29 | buildActionMask = 2147483647; 30 | files = ( 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | 14DAF8592DCB33D60066AE2E = { 38 | isa = PBXGroup; 39 | children = ( 40 | 14DAF8642DCB33D60066AE2E /* ShutdownScheduler */, 41 | 14DAF8632DCB33D60066AE2E /* Products */, 42 | 14EA50F42DCC7AA20096E5C2 /* build_shutdown_scheduler.sh */, 43 | ); 44 | sourceTree = ""; 45 | }; 46 | 14DAF8632DCB33D60066AE2E /* Products */ = { 47 | isa = PBXGroup; 48 | children = ( 49 | 14DAF8622DCB33D60066AE2E /* ShutdownScheduler.app */, 50 | ); 51 | name = Products; 52 | sourceTree = ""; 53 | }; 54 | /* End PBXGroup section */ 55 | 56 | /* Begin PBXNativeTarget section */ 57 | 14DAF8612DCB33D60066AE2E /* ShutdownScheduler */ = { 58 | isa = PBXNativeTarget; 59 | buildConfigurationList = 14DAF8702DCB33D80066AE2E /* Build configuration list for PBXNativeTarget "ShutdownScheduler" */; 60 | buildPhases = ( 61 | 14DAF85E2DCB33D60066AE2E /* Sources */, 62 | 14DAF85F2DCB33D60066AE2E /* Frameworks */, 63 | 14DAF8602DCB33D60066AE2E /* Resources */, 64 | ); 65 | buildRules = ( 66 | ); 67 | dependencies = ( 68 | ); 69 | fileSystemSynchronizedGroups = ( 70 | 14DAF8642DCB33D60066AE2E /* ShutdownScheduler */, 71 | ); 72 | name = ShutdownScheduler; 73 | packageProductDependencies = ( 74 | ); 75 | productName = ShutdownScheduler; 76 | productReference = 14DAF8622DCB33D60066AE2E /* ShutdownScheduler.app */; 77 | productType = "com.apple.product-type.application"; 78 | }; 79 | /* End PBXNativeTarget section */ 80 | 81 | /* Begin PBXProject section */ 82 | 14DAF85A2DCB33D60066AE2E /* Project object */ = { 83 | isa = PBXProject; 84 | attributes = { 85 | BuildIndependentTargetsInParallel = 1; 86 | LastSwiftUpdateCheck = 1630; 87 | LastUpgradeCheck = 1630; 88 | TargetAttributes = { 89 | 14DAF8612DCB33D60066AE2E = { 90 | CreatedOnToolsVersion = 16.3; 91 | }; 92 | }; 93 | }; 94 | buildConfigurationList = 14DAF85D2DCB33D60066AE2E /* Build configuration list for PBXProject "ShutdownScheduler" */; 95 | developmentRegion = en; 96 | hasScannedForEncodings = 0; 97 | knownRegions = ( 98 | en, 99 | Base, 100 | "zh-Hans", 101 | ); 102 | mainGroup = 14DAF8592DCB33D60066AE2E; 103 | minimizedProjectReferenceProxies = 1; 104 | preferredProjectObjectVersion = 77; 105 | productRefGroup = 14DAF8632DCB33D60066AE2E /* Products */; 106 | projectDirPath = ""; 107 | projectRoot = ""; 108 | targets = ( 109 | 14DAF8612DCB33D60066AE2E /* ShutdownScheduler */, 110 | ); 111 | }; 112 | /* End PBXProject section */ 113 | 114 | /* Begin PBXResourcesBuildPhase section */ 115 | 14DAF8602DCB33D60066AE2E /* Resources */ = { 116 | isa = PBXResourcesBuildPhase; 117 | buildActionMask = 2147483647; 118 | files = ( 119 | 14EA50F62DCC7AA60096E5C2 /* build_shutdown_scheduler.sh in Resources */, 120 | ); 121 | runOnlyForDeploymentPostprocessing = 0; 122 | }; 123 | /* End PBXResourcesBuildPhase section */ 124 | 125 | /* Begin PBXSourcesBuildPhase section */ 126 | 14DAF85E2DCB33D60066AE2E /* Sources */ = { 127 | isa = PBXSourcesBuildPhase; 128 | buildActionMask = 2147483647; 129 | files = ( 130 | ); 131 | runOnlyForDeploymentPostprocessing = 0; 132 | }; 133 | /* End PBXSourcesBuildPhase section */ 134 | 135 | /* Begin XCBuildConfiguration section */ 136 | 14DAF86E2DCB33D80066AE2E /* Debug */ = { 137 | isa = XCBuildConfiguration; 138 | buildSettings = { 139 | ALWAYS_SEARCH_USER_PATHS = NO; 140 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 141 | CLANG_ANALYZER_NONNULL = YES; 142 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 143 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 144 | CLANG_ENABLE_MODULES = YES; 145 | CLANG_ENABLE_OBJC_ARC = YES; 146 | CLANG_ENABLE_OBJC_WEAK = YES; 147 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 148 | CLANG_WARN_BOOL_CONVERSION = YES; 149 | CLANG_WARN_COMMA = YES; 150 | CLANG_WARN_CONSTANT_CONVERSION = YES; 151 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 152 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 153 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 154 | CLANG_WARN_EMPTY_BODY = YES; 155 | CLANG_WARN_ENUM_CONVERSION = YES; 156 | CLANG_WARN_INFINITE_RECURSION = YES; 157 | CLANG_WARN_INT_CONVERSION = YES; 158 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 159 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 160 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 161 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 162 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 163 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 164 | CLANG_WARN_STRICT_PROTOTYPES = YES; 165 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 166 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 167 | CLANG_WARN_UNREACHABLE_CODE = YES; 168 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 169 | COPY_PHASE_STRIP = NO; 170 | DEBUG_INFORMATION_FORMAT = dwarf; 171 | DEVELOPMENT_TEAM = 6X2HSWDZCR; 172 | ENABLE_STRICT_OBJC_MSGSEND = YES; 173 | ENABLE_TESTABILITY = YES; 174 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 175 | GCC_C_LANGUAGE_STANDARD = gnu17; 176 | GCC_DYNAMIC_NO_PIC = NO; 177 | GCC_NO_COMMON_BLOCKS = YES; 178 | GCC_OPTIMIZATION_LEVEL = 0; 179 | GCC_PREPROCESSOR_DEFINITIONS = ( 180 | "DEBUG=1", 181 | "$(inherited)", 182 | ); 183 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 184 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 185 | GCC_WARN_UNDECLARED_SELECTOR = YES; 186 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 187 | GCC_WARN_UNUSED_FUNCTION = YES; 188 | GCC_WARN_UNUSED_VARIABLE = YES; 189 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 190 | MACOSX_DEPLOYMENT_TARGET = 15.4; 191 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 192 | MTL_FAST_MATH = YES; 193 | ONLY_ACTIVE_ARCH = YES; 194 | SDKROOT = macosx; 195 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 196 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 197 | }; 198 | name = Debug; 199 | }; 200 | 14DAF86F2DCB33D80066AE2E /* Release */ = { 201 | isa = XCBuildConfiguration; 202 | buildSettings = { 203 | ALWAYS_SEARCH_USER_PATHS = NO; 204 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 205 | CLANG_ANALYZER_NONNULL = YES; 206 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 207 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 208 | CLANG_ENABLE_MODULES = YES; 209 | CLANG_ENABLE_OBJC_ARC = YES; 210 | CLANG_ENABLE_OBJC_WEAK = YES; 211 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 212 | CLANG_WARN_BOOL_CONVERSION = YES; 213 | CLANG_WARN_COMMA = YES; 214 | CLANG_WARN_CONSTANT_CONVERSION = YES; 215 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 216 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 217 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 218 | CLANG_WARN_EMPTY_BODY = YES; 219 | CLANG_WARN_ENUM_CONVERSION = YES; 220 | CLANG_WARN_INFINITE_RECURSION = YES; 221 | CLANG_WARN_INT_CONVERSION = YES; 222 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 223 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 224 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 225 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 226 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 227 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 228 | CLANG_WARN_STRICT_PROTOTYPES = YES; 229 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 230 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 231 | CLANG_WARN_UNREACHABLE_CODE = YES; 232 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 233 | COPY_PHASE_STRIP = NO; 234 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 235 | DEVELOPMENT_TEAM = 6X2HSWDZCR; 236 | ENABLE_NS_ASSERTIONS = NO; 237 | ENABLE_STRICT_OBJC_MSGSEND = YES; 238 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 239 | GCC_C_LANGUAGE_STANDARD = gnu17; 240 | GCC_NO_COMMON_BLOCKS = YES; 241 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 242 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 243 | GCC_WARN_UNDECLARED_SELECTOR = YES; 244 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 245 | GCC_WARN_UNUSED_FUNCTION = YES; 246 | GCC_WARN_UNUSED_VARIABLE = YES; 247 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 248 | MACOSX_DEPLOYMENT_TARGET = 15.4; 249 | MTL_ENABLE_DEBUG_INFO = NO; 250 | MTL_FAST_MATH = YES; 251 | SDKROOT = macosx; 252 | SWIFT_COMPILATION_MODE = wholemodule; 253 | }; 254 | name = Release; 255 | }; 256 | 14DAF8712DCB33D80066AE2E /* Debug */ = { 257 | isa = XCBuildConfiguration; 258 | buildSettings = { 259 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 260 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 261 | CODE_SIGN_ENTITLEMENTS = ShutdownScheduler/ShutdownScheduler.entitlements; 262 | CODE_SIGN_STYLE = Automatic; 263 | COMBINE_HIDPI_IMAGES = YES; 264 | CURRENT_PROJECT_VERSION = 1; 265 | DEVELOPMENT_TEAM = 6X2HSWDZCR; 266 | ENABLE_HARDENED_RUNTIME = YES; 267 | ENABLE_PREVIEWS = YES; 268 | GENERATE_INFOPLIST_FILE = YES; 269 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 270 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 271 | LD_RUNPATH_SEARCH_PATHS = ( 272 | "$(inherited)", 273 | "@executable_path/../Frameworks", 274 | ); 275 | MACOSX_DEPLOYMENT_TARGET = 14.0; 276 | MARKETING_VERSION = 1.00; 277 | PRODUCT_BUNDLE_IDENTIFIER = com.codans.ShutdownScheduler; 278 | PRODUCT_NAME = "$(TARGET_NAME)"; 279 | REGISTER_APP_GROUPS = YES; 280 | SWIFT_EMIT_LOC_STRINGS = YES; 281 | SWIFT_VERSION = 5.0; 282 | }; 283 | name = Debug; 284 | }; 285 | 14DAF8722DCB33D80066AE2E /* Release */ = { 286 | isa = XCBuildConfiguration; 287 | buildSettings = { 288 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 289 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 290 | CODE_SIGN_ENTITLEMENTS = ShutdownScheduler/ShutdownScheduler.entitlements; 291 | CODE_SIGN_STYLE = Automatic; 292 | COMBINE_HIDPI_IMAGES = YES; 293 | CURRENT_PROJECT_VERSION = 1; 294 | DEVELOPMENT_TEAM = 6X2HSWDZCR; 295 | ENABLE_HARDENED_RUNTIME = YES; 296 | ENABLE_PREVIEWS = YES; 297 | GENERATE_INFOPLIST_FILE = YES; 298 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 299 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 300 | LD_RUNPATH_SEARCH_PATHS = ( 301 | "$(inherited)", 302 | "@executable_path/../Frameworks", 303 | ); 304 | MACOSX_DEPLOYMENT_TARGET = 14.0; 305 | MARKETING_VERSION = 1.00; 306 | PRODUCT_BUNDLE_IDENTIFIER = com.codans.ShutdownScheduler; 307 | PRODUCT_NAME = "$(TARGET_NAME)"; 308 | REGISTER_APP_GROUPS = YES; 309 | SWIFT_EMIT_LOC_STRINGS = YES; 310 | SWIFT_VERSION = 5.0; 311 | }; 312 | name = Release; 313 | }; 314 | /* End XCBuildConfiguration section */ 315 | 316 | /* Begin XCConfigurationList section */ 317 | 14DAF85D2DCB33D60066AE2E /* Build configuration list for PBXProject "ShutdownScheduler" */ = { 318 | isa = XCConfigurationList; 319 | buildConfigurations = ( 320 | 14DAF86E2DCB33D80066AE2E /* Debug */, 321 | 14DAF86F2DCB33D80066AE2E /* Release */, 322 | ); 323 | defaultConfigurationIsVisible = 0; 324 | defaultConfigurationName = Release; 325 | }; 326 | 14DAF8702DCB33D80066AE2E /* Build configuration list for PBXNativeTarget "ShutdownScheduler" */ = { 327 | isa = XCConfigurationList; 328 | buildConfigurations = ( 329 | 14DAF8712DCB33D80066AE2E /* Debug */, 330 | 14DAF8722DCB33D80066AE2E /* Release */, 331 | ); 332 | defaultConfigurationIsVisible = 0; 333 | defaultConfigurationName = Release; 334 | }; 335 | /* End XCConfigurationList section */ 336 | }; 337 | rootObject = 14DAF85A2DCB33D60066AE2E /* Project object */; 338 | } 339 | -------------------------------------------------------------------------------- /ShutdownScheduler.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ShutdownScheduler.xcodeproj/xcuserdata/shrek.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | ShutdownScheduler.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /ShutdownScheduler/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | import SwiftUI 3 | import Combine 4 | import Foundation 5 | import os.log 6 | 7 | // 创建一个自定义的NSView类来显示倒计时和图标 8 | class StatusItemView: NSView { 9 | private var timeLabel: NSTextField! 10 | private var iconView: NSImageView! 11 | private var backgroundView: NSView! 12 | 13 | override init(frame frameRect: NSRect) { 14 | super.init(frame: frameRect) 15 | setupView() 16 | } 17 | 18 | required init?(coder: NSCoder) { 19 | super.init(coder: coder) 20 | setupView() 21 | } 22 | 23 | private func setupView() { 24 | // 计算垂直居中的Y坐标 25 | let centerY = (frame.height - 18) / 2 26 | 27 | // 创建不透明背景图层 28 | backgroundView = NSView(frame: NSRect(x: 0, y: 0, width: frame.width, height: frame.height)) 29 | backgroundView.wantsLayer = true 30 | backgroundView.layer?.backgroundColor = NSColor.darkGray.cgColor 31 | addSubview(backgroundView) 32 | 33 | // 创建图标视图 - 将图标放在最左边 34 | iconView = NSImageView(frame: NSRect(x: 8, y: centerY, width: 18, height: 18)) 35 | iconView.imageScaling = .scaleProportionallyDown 36 | addSubview(iconView) 37 | 38 | // 创建时间标签 39 | timeLabel = NSTextField(frame: NSRect(x: 26, y: centerY, width: 50, height: 18)) 40 | timeLabel.isEditable = false 41 | timeLabel.isBordered = false 42 | timeLabel.drawsBackground = false 43 | timeLabel.font = NSFont.boldSystemFont(ofSize: 12) 44 | timeLabel.textColor = NSColor.white // 使用白色文字 45 | timeLabel.alignment = .left 46 | addSubview(timeLabel) 47 | } 48 | 49 | func update(time: String, icon: NSImage?) { 50 | timeLabel.stringValue = time 51 | iconView.image = icon 52 | } 53 | } 54 | 55 | class AppDelegate: NSObject, NSApplicationDelegate { 56 | // 基本UI组件 57 | var window: NSWindow? 58 | var popover = NSPopover() 59 | var statusItem: NSStatusItem? // 单一状态栏项 60 | 61 | // 视图和图标 62 | var statusItemView: StatusItemView? 63 | var originalIcon: NSImage? 64 | 65 | // 状态和计时器 66 | var isCountingDown = false 67 | var remainingSeconds = 0 68 | var selectedAction = "" 69 | var menuBarUpdateTimer: Timer? 70 | 71 | // 设置窗口 72 | private var settingsWindowController: SettingsWindowController? 73 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app.ShutdownScheduler", category: "AppDelegate") 74 | 75 | func applicationDidFinishLaunching(_ notification: Notification) { 76 | print("[调试] 应用程序启动") 77 | // 隐藏dock图标 78 | NSApp.setActivationPolicy(.accessory) 79 | 80 | // 关闭所有窗口 81 | NSApp.windows.forEach { $0.close() } 82 | 83 | // 确保不过度激活应用 84 | NSApp.deactivate() 85 | 86 | // 创建 ContentView 并传入回调函数 87 | let contentView = ContentView(countdownStateChanged: { [weak self] isCountingDown, remainingSeconds, actionType in 88 | guard let self = self else { return } 89 | 90 | self.handleCountdownStateChange(isCountingDown: isCountingDown, remainingSeconds: remainingSeconds, selectedAction: actionType.rawValue) 91 | }) 92 | 93 | // 设置弹出窗口 94 | popover.contentSize = NSSize(width: 350, height: 400) 95 | popover.behavior = .transient 96 | popover.contentViewController = NSHostingController(rootView: contentView) 97 | 98 | // 注册强制重置通知 99 | NotificationCenter.default.addObserver( 100 | self, 101 | selector: #selector(handleForceReset), 102 | name: Notification.Name("ForceStatusItemReset"), 103 | object: nil 104 | ) 105 | 106 | // 注册语言变化通知 107 | NotificationCenter.default.addObserver( 108 | self, 109 | selector: #selector(handleLanguageChanged), 110 | name: Notification.Name("LanguageChanged"), 111 | object: nil 112 | ) 113 | 114 | // 创建状态栏图标 115 | createStatusItem() 116 | 117 | // 显示主界面 118 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in 119 | guard let self = self, let button = self.statusItem?.button else { return } 120 | self.popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 121 | } 122 | } 123 | 124 | @objc func handleForceReset() { 125 | print("[调试] 接收到强制重置通知") 126 | 127 | // 停止定时器 128 | stopMenuBarUpdateTimer() 129 | 130 | // 强制重置状态变量 131 | isCountingDown = false 132 | remainingSeconds = 0 133 | 134 | // 强制删除并重建状态栏 135 | DispatchQueue.main.async { [weak self] in 136 | guard let self = self else { return } 137 | self.displayNormalStatusItem() 138 | } 139 | } 140 | 141 | // 处理语言变化通知 142 | @objc func handleLanguageChanged() { 143 | logger.info("语言设置已更改,正在更新界面") 144 | 145 | // 重新创建状态栏菜单,使用新的语言设置 146 | DispatchQueue.main.async { [weak self] in 147 | guard let self = self else { return } 148 | 149 | // 更新菜单 150 | let menu = self.createMenu() 151 | self.statusItem?.menu = menu 152 | 153 | // 发送通知给主界面更新 154 | NotificationCenter.default.post(name: Notification.Name("RefreshContentView"), object: nil) 155 | } 156 | } 157 | 158 | // MARK: - 倒计时状态更改回调 159 | func handleCountdownStateChange(isCountingDown: Bool, remainingSeconds: Int, selectedAction: String) { 160 | print("[调试] 倒计时状态更改: isCountingDown=\(isCountingDown), remainingSeconds=\(remainingSeconds), selectedAction=\(selectedAction)") 161 | 162 | // 停止任何正在进行的定时器和延迟操作 163 | stopMenuBarUpdateTimer() 164 | 165 | // 设置状态变量 166 | self.isCountingDown = isCountingDown 167 | self.remainingSeconds = remainingSeconds 168 | self.selectedAction = selectedAction 169 | 170 | // 立即清除状态栏显示 171 | DispatchQueue.main.async { 172 | self.statusItem?.button?.subviews.forEach { $0.removeFromSuperview() } 173 | self.statusItemView = nil 174 | 175 | // 根据状态选择显示方式 176 | if isCountingDown { 177 | self.startMenuBarUpdateTimer() 178 | self.updateMenuBarDisplay() // 立即更新显示 179 | } else { 180 | self.displayNormalStatusItem() // 显示正常状态 181 | } 182 | 183 | // 更新菜单项状态 184 | let menu = self.createMenu() 185 | self.statusItem?.menu = menu 186 | } 187 | } 188 | 189 | // MARK: - 状态栏控制 190 | func createStatusItem() { 191 | // 创建一个状态栏项 192 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 193 | 194 | // 加载图标 195 | var icon: NSImage? = nil 196 | 197 | // 先尝试加载自定义图标 198 | if let bundleIcon = NSImage(named: "StatusBarIcon") { 199 | icon = bundleIcon 200 | } else if let customIcon = NSImage(named: "icon_white") { 201 | icon = customIcon 202 | } else if let systemIcon = NSImage(systemSymbolName: "timer", accessibilityDescription: "Timer") { 203 | icon = systemIcon 204 | } 205 | 206 | // 设置图标和大小 207 | icon?.size = NSSize(width: 18, height: 18) 208 | statusItem?.button?.image = icon 209 | originalIcon = icon 210 | 211 | // 设置点击动作 - 左键点击显示主界面 212 | statusItem?.button?.action = #selector(togglePopover(_:)) 213 | 214 | // 添加右键菜单 215 | let rightClickMenu = createMenu() 216 | statusItem?.menu = rightClickMenu 217 | 218 | logger.info("创建状态栏图标: \(icon != nil ? "成功" : "失败")") 219 | } 220 | 221 | 222 | 223 | // 创建右键菜单 224 | func createMenu() -> NSMenu { 225 | let menu = NSMenu() 226 | 227 | // 添加定时关机选项 228 | let shutdownItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "shutdown_menu", defaultValue: "定时关机"), 229 | action: #selector(scheduleShutdown(_:)), 230 | keyEquivalent: "s") 231 | shutdownItem.target = self 232 | menu.addItem(shutdownItem) 233 | 234 | // 添加定时休眠选项 235 | let sleepItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "sleep_menu", defaultValue: "定时休眠"), 236 | action: #selector(scheduleSleep(_:)), 237 | keyEquivalent: "l") 238 | sleepItem.target = self 239 | menu.addItem(sleepItem) 240 | 241 | // 添加取消定时选项 242 | let cancelItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "cancel_menu", defaultValue: "取消定时"), 243 | action: #selector(cancelSchedule(_:)), 244 | keyEquivalent: "c") 245 | cancelItem.target = self 246 | cancelItem.isEnabled = isCountingDown 247 | menu.addItem(cancelItem) 248 | 249 | // 添加分隔线 250 | menu.addItem(NSMenuItem.separator()) 251 | 252 | // 添加设置项 253 | let settingsItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "settings_menu", defaultValue: "设置..."), 254 | action: #selector(openSettings(_:)), 255 | keyEquivalent: ",") 256 | settingsItem.target = self 257 | menu.addItem(settingsItem) 258 | 259 | // 添加分隔线 260 | menu.addItem(NSMenuItem.separator()) 261 | 262 | // 添加关于项 263 | let aboutItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "about_menu", defaultValue: "关于"), 264 | action: #selector(showAbout(_:)), 265 | keyEquivalent: "") 266 | aboutItem.target = self 267 | menu.addItem(aboutItem) 268 | 269 | // 添加退出项 270 | let quitItem = NSMenuItem(title: SettingsManager.shared.localizedString(for: "quit_menu", defaultValue: "退出"), 271 | action: #selector(NSApplication.terminate(_:)), 272 | keyEquivalent: "q") 273 | menu.addItem(quitItem) 274 | 275 | return menu 276 | } 277 | 278 | /// 显示正常状态(非倒计时状态) 279 | private func displayNormalStatusItem() { 280 | print("[调试] 开始恢复正常状态...") 281 | 282 | // 最彻底的解决方案: 完全移除旧状态栏并创建新的 283 | if let oldItem = statusItem { 284 | NSStatusBar.system.removeStatusItem(oldItem) 285 | } 286 | 287 | // 创建新的状态栏项 288 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 289 | 290 | // 加载图标 291 | var icon: NSImage? = nil 292 | 293 | // 先尝试加载自定义图标 294 | if let customIcon = NSImage(named: "icon_white") { 295 | icon = customIcon 296 | } else { 297 | // 使用系统图标 298 | icon = NSImage(systemSymbolName: "power", accessibilityDescription: "Shutdown") 299 | } 300 | 301 | icon?.size = NSSize(width: 18, height: 18) 302 | originalIcon = icon 303 | 304 | // 设置状态栏图标 305 | statusItem?.button?.image = icon 306 | 307 | // 设置点击动作 - 左键点击显示主界面 308 | statusItem?.button?.action = #selector(togglePopover(_:)) 309 | 310 | // 添加右键菜单 311 | let rightClickMenu = createMenu() 312 | statusItem?.menu = rightClickMenu 313 | 314 | // 清理引用 315 | statusItemView = nil 316 | 317 | print("[调试] 恢复正常状态显示 - 完成") 318 | } 319 | 320 | /// 显示倒计时状态 321 | private func displayCountdownView(time: String, icon: NSImage?) { 322 | // 当前状态是否倒计时 323 | if !isCountingDown { 324 | print("[调试] 已取消显示倒计时,当前不在倒计时状态") 325 | return 326 | } 327 | 328 | // 在全局队列中执行,确保与其他状态同步 329 | DispatchQueue.global().async { 330 | // 再次检查状态,可能已经变化 331 | if !self.isCountingDown { 332 | print("[调试] 再次取消显示倒计时,状态已改变") 333 | return 334 | } 335 | 336 | // 在主线程中更新UI 337 | DispatchQueue.main.async { 338 | // 再次最终检查 339 | if !self.isCountingDown { 340 | print("[调试] 最终取消显示倒计时,状态已改变") 341 | return 342 | } 343 | 344 | // 先彻底清除之前的内容 345 | self.statusItem?.button?.subviews.forEach { $0.removeFromSuperview() } 346 | self.statusItem?.button?.image = nil 347 | self.statusItem?.button?.title = "" 348 | 349 | // 使用黑色样式 350 | if let button = self.statusItem?.button { 351 | button.appearance = NSAppearance(named: .darkAqua) 352 | } 353 | 354 | // 设置固定长度 355 | self.statusItem?.length = 90 356 | 357 | // 创建自定义视图 358 | let view = StatusItemView(frame: NSRect(x: 0, y: 0, width: 90, height: 22)) 359 | self.statusItemView = view 360 | 361 | print("[调试] 准备显示倒计时状态,当前状态: \(self.isCountingDown)") 362 | 363 | // 再次确认状态,可能在延迟期间状态变化 364 | if self.isCountingDown { 365 | self.statusItem?.button?.addSubview(view) 366 | view.update(time: time, icon: icon) 367 | print("[调试] 显示倒计时: 时间=\(time)") 368 | } else { 369 | print("[调试] 最后一刻取消显示倒计时状态") 370 | self.displayNormalStatusItem() 371 | } 372 | } 373 | } 374 | } 375 | 376 | @objc func togglePopover(_ sender: AnyObject?) { 377 | guard let button = statusItem?.button else { return } 378 | 379 | if popover.isShown { 380 | popover.performClose(sender) 381 | } else { 382 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 383 | NSApp.activate(ignoringOtherApps: true) 384 | } 385 | } 386 | 387 | 388 | 389 | 390 | 391 | // 定时关机菜单项处理 392 | @objc func scheduleShutdown(_ sender: AnyObject?) { 393 | // 显示主界面,并选择关机选项 394 | guard let button = statusItem?.button else { return } 395 | 396 | // 显示主界面 397 | if !popover.isShown { 398 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 399 | NSApp.activate(ignoringOtherApps: true) 400 | } 401 | 402 | // 发送通知以选择关机选项 403 | NotificationCenter.default.post(name: Notification.Name("SelectShutdownAction"), object: nil) 404 | } 405 | 406 | // 定时休眠菜单项处理 407 | @objc func scheduleSleep(_ sender: AnyObject?) { 408 | // 显示主界面,并选择休眠选项 409 | guard let button = statusItem?.button else { return } 410 | 411 | // 显示主界面 412 | if !popover.isShown { 413 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: NSRectEdge.minY) 414 | NSApp.activate(ignoringOtherApps: true) 415 | } 416 | 417 | // 发送通知以选择休眠选项 418 | NotificationCenter.default.post(name: Notification.Name("SelectSleepAction"), object: nil) 419 | } 420 | 421 | // 取消定时菜单项处理 422 | @objc func cancelSchedule(_ sender: AnyObject?) { 423 | // 取消当前的定时任务 424 | if isCountingDown { 425 | logger.info("通过菜单取消定时任务") 426 | isCountingDown = false 427 | remainingSeconds = 0 428 | 429 | // 停止定时器 430 | stopMenuBarUpdateTimer() 431 | 432 | // 恢复正常状态 433 | displayNormalStatusItem() 434 | 435 | // 取消系统定时任务 436 | cancelSystemTask() 437 | 438 | // 通知 ContentView 更新状态 439 | NotificationCenter.default.post(name: Notification.Name("ForceStatusItemReset"), object: nil) 440 | 441 | // 更新菜单状态 442 | let menu = self.createMenu() 443 | self.statusItem?.menu = menu 444 | 445 | // 显示通知 446 | let notification = NSUserNotification() 447 | notification.title = SettingsManager.shared.localizedString(for: "cancel_notification_title", defaultValue: "定时已取消") 448 | notification.informativeText = SettingsManager.shared.localizedString(for: "cancel_notification_text", defaultValue: "定时关机/休眠任务已取消") 449 | NSUserNotificationCenter.default.deliver(notification) 450 | } 451 | } 452 | 453 | // 取消系统定时任务 454 | func cancelSystemTask() { 455 | // 通过通知中心发送取消任务的通知给 ContentView 456 | NotificationCenter.default.post(name: Notification.Name("CancelAllTasks"), object: nil) 457 | logger.info("已发送取消所有任务的通知") 458 | } 459 | 460 | @objc func openSettings(_ sender: AnyObject?) { 461 | // 如果设置窗口控制器不存在,创建一个 462 | if settingsWindowController == nil { 463 | settingsWindowController = SettingsWindowController() 464 | } 465 | 466 | // 显示设置窗口并激活 467 | settingsWindowController?.showWindow(sender) 468 | settingsWindowController?.window?.makeKeyAndOrderFront(sender) 469 | NSApp.activate(ignoringOtherApps: true) 470 | } 471 | 472 | // 显示关于对话框 473 | @objc func showAbout(_ sender: AnyObject?) { 474 | // 创建并显示关于对话框 475 | let alert = NSAlert() 476 | alert.messageText = getLocalizedString(for: "app_title", defaultValue: "定时关机/休眠工具") 477 | 478 | // 从应用程序的Bundle中获取真实版本号 479 | let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" 480 | let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" 481 | 482 | alert.informativeText = "Version: \(version) (\(build))\n© 2025 Hu Gang\n\nhttps://github.com/ihugang/ShutdownScheduler" 483 | alert.alertStyle = .informational 484 | alert.addButton(withTitle: "OK") 485 | 486 | // 添加访问GitHub按钮 487 | let githubButton = alert.addButton(withTitle: getLocalizedString(for: "visit_github", defaultValue: "访问GitHub")) 488 | 489 | // 显示对话框并获取用户点击的按钮 490 | let response = alert.runModal() 491 | 492 | // 如果用户点击了访问GitHub按钮 493 | if response == .alertSecondButtonReturn { 494 | // 打开GitHub链接 495 | if let url = URL(string: "https://github.com/ihugang/ShutdownScheduler") { 496 | NSWorkspace.shared.open(url) 497 | } 498 | } 499 | } 500 | 501 | // 获取本地化字符串 502 | func getLocalizedString(for key: String, defaultValue: String) -> String { 503 | return SettingsManager.shared.localizedString(for: key, defaultValue: defaultValue) 504 | } 505 | 506 | // 启动菜单栏更新定时器 507 | func startMenuBarUpdateTimer() { 508 | // 停止现有定时器(如果有) 509 | stopMenuBarUpdateTimer() 510 | 511 | // 如果不在倒计时状态,不启动定时器 512 | if !isCountingDown { 513 | print("[调试] 取消启动定时器,因为当前不在倒计时状态") 514 | return 515 | } 516 | 517 | print("[调试] 启动菜单栏更新定时器") 518 | 519 | // 创建新定时器,每秒更新一次 520 | menuBarUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in 521 | guard let self = self else { 522 | timer.invalidate() // 自我清理 523 | return 524 | } 525 | 526 | // 再次检查状态,防止在非倒计时状态下更新 527 | if !self.isCountingDown { 528 | timer.invalidate() 529 | self.menuBarUpdateTimer = nil 530 | print("[调试] 定时器自动停止,当前状态不再倒计时") 531 | self.displayNormalStatusItem() // 强制恢复正常状态 532 | return 533 | } 534 | 535 | self.updateMenuBarDisplay() 536 | } 537 | 538 | // 确保定时器添加到当前运行循环 539 | RunLoop.current.add(menuBarUpdateTimer!, forMode: .common) 540 | } 541 | 542 | // 停止菜单栏更新定时器 543 | func stopMenuBarUpdateTimer() { 544 | // 在主线程停止定时器 545 | DispatchQueue.main.async { 546 | // 取消所有正在的延迟调用 547 | DispatchQueue.main.async { 548 | self.menuBarUpdateTimer?.invalidate() 549 | self.menuBarUpdateTimer = nil 550 | print("[调试] 偏然停止所有菜单栏定时器") 551 | } 552 | } 553 | } 554 | 555 | // 更新菜单栏显示 556 | private func updateMenuBarDisplay() { 557 | // 进行安全检查,确保当前真的在倒计时状态 558 | if !isCountingDown { 559 | print("[调试] 取消菜单栏更新,当前不在倒计时状态") 560 | // 强制恢复正常状态 561 | DispatchQueue.main.async { 562 | self.displayNormalStatusItem() 563 | } 564 | return 565 | } 566 | 567 | // 如果正在倒计时 568 | // 格式化倒计时时间 569 | let minutes = remainingSeconds / 60 570 | let seconds = remainingSeconds % 60 571 | let formattedTime = String(format: "%d:%02d", minutes, seconds) 572 | 573 | // 准备图标 574 | var icon: NSImage? = nil 575 | if selectedAction == "关机" { 576 | // 关机模式 577 | let powerIcon = NSImage(systemSymbolName: "power", accessibilityDescription: "Shutdown") 578 | powerIcon?.size = NSSize(width: 18, height: 18) 579 | icon = powerIcon 580 | } else { 581 | // 休眠模式 582 | let sleepIcon = NSImage(systemSymbolName: "moon.fill", accessibilityDescription: "Sleep") 583 | sleepIcon?.size = NSSize(width: 18, height: 18) 584 | icon = sleepIcon 585 | } 586 | 587 | // 在主线程中更新UI,但再次检查状态 588 | DispatchQueue.main.async { 589 | // 最终一次检查状态 590 | if !self.isCountingDown { 591 | print("[调试] 取消菜单栏更新,在开始显示前检测到状态变化") 592 | self.displayNormalStatusItem() 593 | return 594 | } 595 | 596 | if self.statusItemView != nil { 597 | // 只更新已有视图的内容 598 | self.statusItemView?.update(time: formattedTime, icon: icon) 599 | } else { 600 | // 首次显示倒计时 601 | self.displayCountdownView(time: formattedTime, icon: icon) 602 | } 603 | } 604 | } 605 | 606 | // 格式化倒计时时间 607 | func formatTimeRemaining(seconds: Int) -> String { 608 | let hours = seconds / 3600 609 | let minutes = (seconds % 3600) / 60 610 | let seconds = seconds % 60 611 | 612 | if hours > 0 { 613 | return String(format: "%02d:%02d:%02d", hours, minutes, seconds) 614 | } else { 615 | return String(format: "%02d:%02d", minutes, seconds) 616 | } 617 | } 618 | } 619 | -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/128.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/16.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/256.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/32.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/512.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/64.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "32.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "256.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "512.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/icon_white.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "close_icon_16.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "close_icon_32.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "close_icon_48.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_16.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_32.png -------------------------------------------------------------------------------- /ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/Assets.xcassets/icon_white.imageset/close_icon_48.png -------------------------------------------------------------------------------- /ShutdownScheduler/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import OSLog 3 | import Combine 4 | 5 | struct ContentView: View { 6 | // 添加刷新视图的状态变量 7 | @State private var refreshID = UUID() 8 | 9 | // 本地化字符串辅助函数 10 | private func localizedString(for key: String, defaultValue: String) -> String { 11 | return SettingsManager.shared.localizedString(for: key, defaultValue: defaultValue) 12 | } 13 | // 状态变化回调函数类型:isCountingDown, remainingSeconds, actionType 14 | var countdownStateChanged: ((Bool, Int, ActionType) -> Void)? = nil 15 | 16 | init(countdownStateChanged: ((Bool, Int, ActionType) -> Void)? = nil) { 17 | self.countdownStateChanged = countdownStateChanged 18 | } 19 | // 添加日志记录器 20 | private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app.ShutdownScheduler", category: "ContentView") 21 | 22 | @State private var minutes: Int = 30 23 | @State private var feedback: String = "" 24 | @State private var selectedAction: ActionType = .shutdown 25 | @State private var isCountingDown: Bool = false 26 | @State private var remainingSeconds: Int = 0 27 | @State private var countdownTimer: Timer? = nil 28 | @State private var endTime: Date? = nil 29 | @State private var commandOutput: String = "" 30 | @State private var scheduledJobLabels: [String] = [] 31 | @State private var scheduledJobPaths: [String] = [] 32 | // 创建通知发布者 33 | private let shutdownNotification = NotificationCenter.default.publisher(for: Notification.Name("SelectShutdownAction")) 34 | private let sleepNotification = NotificationCenter.default.publisher(for: Notification.Name("SelectSleepAction")) 35 | private let refreshViewNotification = NotificationCenter.default.publisher(for: Notification.Name("RefreshContentView")) 36 | private let cancelAllTasksNotification = NotificationCenter.default.publisher(for: Notification.Name("CancelAllTasks")) 37 | 38 | var body: some View { 39 | VStack(spacing: 20) { 40 | Text(localizedString(for: "app_title", defaultValue: "定时关机/休眠工具")) 41 | .font(.headline) 42 | .padding(.bottom, 5) 43 | 44 | if isCountingDown { 45 | // 显示倒计时 46 | VStack(spacing: 15) { 47 | Text(String(format: localizedString(for: "countdown", defaultValue: "倒计时中: %@"), formatTimeRemaining(seconds: remainingSeconds))) 48 | .font(.title) 49 | .foregroundColor(.blue) 50 | .frame(maxWidth: .infinity, alignment: .center) 51 | 52 | Text(String(format: localizedString(for: "estimated_time", defaultValue: "预计%@时间: %@"), localizedString(for: selectedAction.rawValue, defaultValue: selectedAction.rawValue), formatDate(endTime))) 53 | .font(.subheadline) 54 | .frame(maxWidth: .infinity, alignment: .center) 55 | 56 | Button(localizedString(for: "cancel_task", defaultValue: "取消任务")) { 57 | cancelAction() 58 | } 59 | .foregroundColor(.white) 60 | .buttonStyle(PlainButtonStyle()) 61 | .padding(.vertical, 8) 62 | .padding(.horizontal, 20) 63 | .background(Color.red) 64 | .cornerRadius(8) 65 | .padding(.top, 10) 66 | } 67 | .padding() 68 | .background(Color.gray.opacity(0.1)) 69 | .cornerRadius(10) 70 | } else { 71 | // 设置界面 72 | VStack(spacing: 15) { 73 | // 输入区域 - Spin模式 74 | HStack { 75 | Text(localizedString(for: "delay_minutes", defaultValue: "延时分钟:")) 76 | .frame(width: 80, alignment: .leading) 77 | 78 | // 减少按钮 79 | Button(action: { 80 | if minutes > 1 { 81 | minutes -= 1 82 | } 83 | }) { 84 | Image(systemName: "minus.circle") 85 | .foregroundColor(.blue) 86 | } 87 | .buttonStyle(BorderlessButtonStyle()) 88 | 89 | // 显示当前分钟数 90 | Text("\(minutes)") 91 | .frame(width: 40) 92 | .padding(.horizontal, 8) 93 | .padding(.vertical, 4) 94 | .background(Color.gray.opacity(0.1)) 95 | .cornerRadius(6) 96 | .overlay( 97 | RoundedRectangle(cornerRadius: 6) 98 | .stroke(Color.gray.opacity(0.3), lineWidth: 1) 99 | ) 100 | 101 | // 增加按钮 102 | Button(action: { 103 | minutes += 1 104 | }) { 105 | Image(systemName: "plus.circle") 106 | .foregroundColor(.blue) 107 | } 108 | .buttonStyle(BorderlessButtonStyle()) 109 | 110 | Spacer() 111 | } 112 | .frame(maxWidth: 250) 113 | 114 | // 操作类型选择器 115 | HStack { 116 | Text(localizedString(for: "action_type", defaultValue: "操作类型:")) 117 | .frame(width: 80, alignment: .leading) 118 | 119 | Picker("", selection: $selectedAction) { 120 | Text(localizedString(for: "shutdown", defaultValue: "关机")).tag(ActionType.shutdown) 121 | Text(localizedString(for: "sleep", defaultValue: "休眠")).tag(ActionType.sleep) 122 | } 123 | .pickerStyle(SegmentedPickerStyle()) 124 | .frame(width: 150) 125 | 126 | Spacer() 127 | } 128 | .frame(maxWidth: 250) 129 | 130 | // 按钮 131 | Button(localizedString(for: "start_countdown", defaultValue: "开始倒计时")) { 132 | executeAction(minutes: minutes, actionType: selectedAction) 133 | } 134 | .buttonStyle(PlainButtonStyle()) 135 | .foregroundColor(.white) 136 | .padding(.vertical, 8) 137 | .padding(.horizontal, 20) 138 | .background( 139 | // 使用ZStack确保只有一层背景 140 | ZStack { 141 | Color.blue 142 | } 143 | ) 144 | .cornerRadius(8) 145 | .padding(.top, 5) 146 | } 147 | .padding() 148 | .background(Color.gray.opacity(0.1)) 149 | .cornerRadius(10) 150 | } 151 | 152 | // 反馈信息 153 | Text(feedback) 154 | .foregroundColor(.gray) 155 | .font(.caption) 156 | .frame(maxWidth: .infinity, alignment: .center) 157 | .padding(.top, 5) 158 | // 添加日志显示区域 159 | if !commandOutput.isEmpty { 160 | VStack(alignment: .leading, spacing: 5) { 161 | Text("命令日志:") 162 | .font(.caption.bold()) 163 | .frame(maxWidth: .infinity, alignment: .leading) 164 | 165 | ScrollView { 166 | Text(commandOutput) 167 | .font(.system(.caption, design: .monospaced)) 168 | .frame(maxWidth: .infinity, alignment: .leading) 169 | .padding(8) 170 | .background(Color.black.opacity(0.05)) 171 | .cornerRadius(8) 172 | } 173 | } 174 | .frame(maxHeight: 150) 175 | .padding(.top, 5) 176 | .padding(.horizontal, 5) 177 | } 178 | } 179 | .padding() 180 | .onReceive(shutdownNotification) { _ in 181 | selectedAction = .shutdown 182 | minutes = 30 183 | // 可选:自动开始倒计时 184 | // executeAction(minutes: minutes, actionType: selectedAction) 185 | } 186 | .onReceive(sleepNotification) { _ in 187 | selectedAction = .sleep 188 | minutes = 30 189 | // 可选:自动开始倒计时 190 | // executeAction(minutes: minutes, actionType: selectedAction) 191 | } 192 | // 监听刷新界面通知,当语言变化时刷新界面 193 | .onReceive(refreshViewNotification) { _ in 194 | // 强制刷新界面 195 | // 更新 refreshID 状态变量来触发界面刷新 196 | refreshID = UUID() 197 | } 198 | // 监听取消所有任务的通知 199 | .onReceive(cancelAllTasksNotification) { _ in 200 | logger.info("接收到取消所有任务的通知") 201 | // 取消所有计划任务 202 | cancelAllScheduledJobs() 203 | // 停止倒计时 204 | stopCountdown() 205 | } 206 | .onDisappear { 207 | stopCountdown() 208 | } 209 | // 使用 refreshID 作为整个视图的 ID,确保语言变化时视图会完全重新创建 210 | .id(refreshID) 211 | } 212 | 213 | @State private var showingAuthAlert = false 214 | @State private var pendingAction: (()->Void)? = nil 215 | 216 | func executeAction(minutes: Int, actionType: ActionType) { 217 | guard minutes > 0 else { 218 | feedback = "请输入有效的分钟数" 219 | return 220 | } 221 | 222 | let actionName: String 223 | let secondsDelay = minutes * 60 224 | 225 | // 清空之前的命令输出 226 | commandOutput = "" 227 | 228 | // 计算目标时间 229 | let targetTime = Date().addingTimeInterval(TimeInterval(secondsDelay)) 230 | let calendar = Calendar.current 231 | let hour = calendar.component(.hour, from: targetTime) 232 | let minute = calendar.component(.minute, from: targetTime) 233 | 234 | // 显示提示,告知用户需要输入管理员密码 235 | feedback = "即将设置\(minutes)分钟后\(actionType.rawValue),需要您输入管理员密码" 236 | 237 | // 先启动倒计时显示,让用户可以看到剩余时间 238 | startCountdown(seconds: secondsDelay, actionType: actionType) 239 | 240 | switch actionType { 241 | case .shutdown: 242 | actionName = "关机" 243 | 244 | // 使用at命令计划关机 245 | let result = scheduleOneTimeShutdown(atHour: hour, minute: minute) 246 | if result.success { 247 | feedback = "已设置 \(minutes) 分钟后\(actionName)" 248 | } else { 249 | feedback = "设置\(actionName)失败,可能需要管理员权限" 250 | appendToCommandOutput("错误: \(result.output)") 251 | stopCountdown() 252 | } 253 | 254 | case .sleep: 255 | actionName = "休眠" 256 | 257 | // 使用at命令计划休眠 258 | let result = scheduleOneTimeSleep(atHour: hour, minute: minute) 259 | if result.success { 260 | feedback = "已设置 \(minutes) 分钟后\(actionName)" 261 | } else { 262 | feedback = "设置\(actionName)失败,可能需要管理员权限" 263 | appendToCommandOutput("错误: \(result.output)") 264 | stopCountdown() 265 | } 266 | } 267 | } 268 | 269 | // 请求管理员权限并执行命令 270 | func requestAdminPrivilegesAndExecute(command: String, actionName: String, secondsDelay: Int) { 271 | // 先重置状态,确保处于非倒计时状态 272 | countdownStateChanged?(false, 0, selectedAction) // 强制通知AppDelegate重置statusItem 273 | isCountingDown = false 274 | stopCountdown() 275 | 276 | // 等待状态清除 277 | let group = DispatchGroup() 278 | group.enter() 279 | 280 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 281 | group.leave() 282 | } 283 | 284 | group.wait() 285 | 286 | // 执行命令 287 | logger.info("执行命令: \(command)") 288 | let script = """ 289 | do shell script "\(command)" with administrator privileges 290 | """ 291 | 292 | let result = runAppleScript(script: script) 293 | logger.info("命令执行结果: \(result.output)") 294 | 295 | // 更新命令输出 296 | appendToCommandOutput("执行命令: \(command)") 297 | appendToCommandOutput("结果: \(result.output)") 298 | 299 | // 检测是否取消了权限请求 300 | if result.output == "USER_CANCELED" { 301 | feedback = "您取消了管理员权限请求" 302 | 303 | // 再次确保重置状态 304 | let notificationName = Notification.Name("ForceStatusItemReset") 305 | NotificationCenter.default.post(name: notificationName, object: nil) // 发送强制重置通知 306 | 307 | DispatchQueue.main.async { 308 | self.countdownStateChanged?(false, 0, self.selectedAction) 309 | self.isCountingDown = false 310 | self.stopCountdown() 311 | } 312 | 313 | return 314 | } 315 | 316 | // 检查启动倒计时的条件 317 | if result.success && !result.output.isEmpty { 318 | // 成功执行命令,启动倒计时 319 | feedback = "已设置 \(secondsDelay / 60) 分钟后\(actionName)" 320 | 321 | // 清空之前的任何倒计时状态 322 | stopCountdown() 323 | 324 | // 等待一小段时间再开始倒计时,确保上一次的状态已清除 325 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 326 | // 开始新的倒计时 327 | self.startCountdown(seconds: secondsDelay, actionType: .shutdown) 328 | } 329 | } else { 330 | // 执行失败 331 | feedback = "命令执行失败" 332 | 333 | // 确保再次重置状态 334 | countdownStateChanged?(false, 0, selectedAction) 335 | isCountingDown = false 336 | stopCountdown() 337 | } 338 | } 339 | 340 | func cancelAction() { 341 | // 取消所有计划任务 342 | cancelAllScheduledJobs() 343 | 344 | feedback = "已取消所有计划任务" 345 | 346 | // 停止倒计时 347 | stopCountdown() 348 | 349 | // 通知状态变化 350 | countdownStateChanged?(false, 0, selectedAction) 351 | } 352 | 353 | // 计划一次性休眠任务 354 | func scheduleOneTimeSleep(atHour hour: Int, minute: Int) -> (success: Bool, output: String) { 355 | let timeString = String(format: "%02d:%02d", hour, minute) 356 | 357 | // 创建一个唯一的标识符 358 | let jobLabel = "com.app.shutdownscheduler.sleep."+UUID().uuidString 359 | 360 | // 创建临时plist文件路径 361 | let tempDir = FileManager.default.temporaryDirectory 362 | let plistPath = tempDir.appendingPathComponent("\(jobLabel).plist") 363 | 364 | // 获取当前日期并设置目标时间 365 | let calendar = Calendar.current 366 | var dateComponents = calendar.dateComponents([.year, .month, .day], from: Date()) 367 | dateComponents.hour = hour 368 | dateComponents.minute = minute 369 | dateComponents.second = 0 370 | 371 | guard let targetDate = calendar.date(from: dateComponents) else { 372 | return (false, "无法创建目标日期") 373 | } 374 | 375 | // 如果目标时间已经过去,则设置为明天的同一时间 376 | var finalDate = targetDate 377 | if finalDate < Date() { 378 | finalDate = calendar.date(byAdding: .day, value: 1, to: targetDate) ?? targetDate 379 | } 380 | 381 | // 创建plist内容 382 | let plistContent = """ 383 | 384 | 385 | 386 | 387 | Label 388 | \(jobLabel) 389 | ProgramArguments 390 | 391 | /usr/bin/pmset 392 | sleepnow 393 | 394 | StartCalendarInterval 395 | 396 | Hour 397 | \(calendar.component(.hour, from: finalDate)) 398 | Minute 399 | \(calendar.component(.minute, from: finalDate)) 400 | 401 | 402 | 403 | """ 404 | 405 | // 写入plist文件 406 | do { 407 | try plistContent.write(to: plistPath, atomically: true, encoding: .utf8) 408 | } catch { 409 | return (false, "无法创建plist文件: \(error.localizedDescription)") 410 | } 411 | 412 | // 使用launchctl加载plist 413 | let script = """ 414 | do shell script "launchctl load \(plistPath.path)" with administrator privileges 415 | """ 416 | 417 | let result = runAppleScript(script: script) 418 | 419 | if result.success { 420 | // 保存任务标识符以便后续取消 421 | scheduledJobLabels.append(jobLabel) 422 | scheduledJobPaths.append(plistPath.path) 423 | } 424 | 425 | appendToCommandOutput("计划休眠任务: \(timeString)") 426 | appendToCommandOutput("结果: \(result.output.isEmpty ? "成功" : result.output)") 427 | 428 | return result 429 | } 430 | 431 | // 计划一次性关机任务 432 | func scheduleOneTimeShutdown(atHour hour: Int, minute: Int) -> (success: Bool, output: String) { 433 | let timeString = String(format: "%02d:%02d", hour, minute) 434 | 435 | // 创建一个唯一的标识符 436 | let jobLabel = "com.app.shutdownscheduler.shutdown."+UUID().uuidString 437 | 438 | // 创建临时plist文件路径 439 | let tempDir = FileManager.default.temporaryDirectory 440 | let plistPath = tempDir.appendingPathComponent("\(jobLabel).plist") 441 | 442 | // 获取当前日期并设置目标时间 443 | let calendar = Calendar.current 444 | var dateComponents = calendar.dateComponents([.year, .month, .day], from: Date()) 445 | dateComponents.hour = hour 446 | dateComponents.minute = minute 447 | dateComponents.second = 0 448 | 449 | guard let targetDate = calendar.date(from: dateComponents) else { 450 | return (false, "无法创建目标日期") 451 | } 452 | 453 | // 如果目标时间已经过去,则设置为明天的同一时间 454 | var finalDate = targetDate 455 | if finalDate < Date() { 456 | finalDate = calendar.date(byAdding: .day, value: 1, to: targetDate) ?? targetDate 457 | } 458 | 459 | // 创建plist内容 460 | let plistContent = """ 461 | 462 | 463 | 464 | 465 | Label 466 | \(jobLabel) 467 | ProgramArguments 468 | 469 | /sbin/shutdown 470 | -h 471 | now 472 | 473 | StartCalendarInterval 474 | 475 | Hour 476 | \(calendar.component(.hour, from: finalDate)) 477 | Minute 478 | \(calendar.component(.minute, from: finalDate)) 479 | 480 | 481 | 482 | """ 483 | 484 | // 写入plist文件 485 | do { 486 | try plistContent.write(to: plistPath, atomically: true, encoding: .utf8) 487 | } catch { 488 | return (false, "无法创建plist文件: \(error.localizedDescription)") 489 | } 490 | 491 | // 使用launchctl加载plist 492 | let script = """ 493 | do shell script "launchctl load \(plistPath.path)" with administrator privileges 494 | """ 495 | 496 | let result = runAppleScript(script: script) 497 | 498 | if result.success { 499 | // 保存任务标识符以便后续取消 500 | scheduledJobLabels.append(jobLabel) 501 | scheduledJobPaths.append(plistPath.path) 502 | } 503 | 504 | appendToCommandOutput("计划关机任务: \(timeString)") 505 | appendToCommandOutput("结果: \(result.output.isEmpty ? "成功" : result.output)") 506 | 507 | return result 508 | } 509 | 510 | // 取消所有计划任务 511 | func cancelAllScheduledJobs() { 512 | var allSuccess = true 513 | var output = "" 514 | 515 | // 如果没有计划任务,直接返回 516 | if scheduledJobLabels.isEmpty { 517 | appendToCommandOutput("没有计划任务需要取消") 518 | return 519 | } 520 | 521 | // 遍历所有计划任务 522 | for (index, jobLabel) in scheduledJobLabels.enumerated() { 523 | let plistPath = scheduledJobPaths[index] 524 | 525 | // 卸载任务 526 | let script = """ 527 | do shell script "launchctl unload \(plistPath)" with administrator privileges 528 | """ 529 | 530 | let result = runAppleScript(script: script) 531 | 532 | if !result.success { 533 | allSuccess = false 534 | output += "\n\(jobLabel): \(result.output)" 535 | } 536 | 537 | // 尝试删除plist文件 538 | do { 539 | try FileManager.default.removeItem(atPath: plistPath) 540 | } catch { 541 | output += "\n无法删除文件 \(plistPath): \(error.localizedDescription)" 542 | } 543 | } 544 | 545 | // 清空任务列表 546 | scheduledJobLabels.removeAll() 547 | scheduledJobPaths.removeAll() 548 | 549 | appendToCommandOutput("取消所有计划任务") 550 | appendToCommandOutput("结果: " + (output.isEmpty ? "成功" : output)) 551 | } 552 | 553 | // 开始倒计时 554 | func startCountdown(seconds: Int, actionType: ActionType) { 555 | // 停止之前的倒计时(如果有) 556 | stopCountdown() 557 | 558 | remainingSeconds = seconds 559 | isCountingDown = true 560 | 561 | // 计算结束时间 562 | endTime = Date().addingTimeInterval(TimeInterval(seconds)) 563 | 564 | // 通知状态变化 565 | print("[调试] ContentView: 开始倒计时,触发回调函数") 566 | countdownStateChanged?(true, remainingSeconds, selectedAction) 567 | 568 | // 创建定时器,每秒更新一次 569 | countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in 570 | 571 | if remainingSeconds > 0 { 572 | remainingSeconds -= 1 573 | // 通知状态变化 574 | print("[调试] ContentView: 倒计时更新,剩余时间: \(remainingSeconds)") 575 | countdownStateChanged?(true, remainingSeconds, selectedAction) 576 | } else { 577 | // 倒计时结束,执行相应操作 578 | executeActionWhenCountdownEnds(actionType: actionType) 579 | stopCountdown() 580 | } 581 | } 582 | } 583 | 584 | // 停止倒计时 585 | func stopCountdown() { 586 | countdownTimer?.invalidate() 587 | countdownTimer = nil 588 | isCountingDown = false 589 | 590 | // 通知状态变化 591 | print("[调试] ContentView: 停止倒计时,触发回调函数") 592 | countdownStateChanged?(false, 0, selectedAction) 593 | } 594 | 595 | // 倒计时结束时执行相应操作 596 | func executeActionWhenCountdownEnds(actionType: ActionType) { 597 | switch actionType { 598 | case .shutdown: 599 | // 使用AppleScript直接执行关机命令,避免优先级反转 600 | let script = "tell application \"Finder\" to shut down" 601 | 602 | // 在主线程上执行AppleScript 603 | let result = runAppleScript(script: script) 604 | 605 | self.logger.info("执行关机命令结果: \(result.output)") 606 | self.appendToCommandOutput("执行关机命令") 607 | 608 | if result.success { 609 | self.appendToCommandOutput("结果: 成功") 610 | } else { 611 | self.appendToCommandOutput("结果: \(result.output)") 612 | self.feedback = "关机命令执行失败: \(result.output)" 613 | } 614 | 615 | case .sleep: 616 | // 使用AppleScript直接执行休眠命令,避免优先级反转 617 | let script = "tell application \"Finder\" to sleep" 618 | 619 | // 在主线程上执行AppleScript 620 | let result = runAppleScript(script: script) 621 | 622 | self.logger.info("执行休眠命令结果: \(result.output)") 623 | self.appendToCommandOutput("执行休眠命令") 624 | 625 | if result.success { 626 | self.appendToCommandOutput("结果: 成功") 627 | } else { 628 | self.appendToCommandOutput("结果: \(result.output)") 629 | self.feedback = "休眠命令执行失败: \(result.output)" 630 | } 631 | } 632 | } 633 | 634 | // 格式化倒计时时间 635 | func formatTimeRemaining(seconds: Int) -> String { 636 | let hours = seconds / 3600 637 | let minutes = (seconds % 3600) / 60 638 | let seconds = seconds % 60 639 | 640 | if hours > 0 { 641 | return String(format: "%02d:%02d:%02d", hours, minutes, seconds) 642 | } else { 643 | return String(format: "%02d:%02d", minutes, seconds) 644 | } 645 | } 646 | 647 | // 格式化日期 648 | func formatDate(_ date: Date?) -> String { 649 | guard let date = date else { return "--" } 650 | 651 | let formatter = DateFormatter() 652 | formatter.dateFormat = "HH:mm:ss" 653 | return formatter.string(from: date) 654 | } 655 | 656 | // 运行AppleScript并返回结果 657 | func runAppleScript(script: String) -> (success: Bool, output: String) { 658 | // 使用NSAppleScript更可靠地处理取消操作 659 | let appleScript = NSAppleScript(source: script) 660 | var errorDict: NSDictionary? 661 | var descriptor: NSAppleEventDescriptor? 662 | DispatchQueue.global(qos: .userInitiated).sync { 663 | descriptor = appleScript?.executeAndReturnError(&errorDict) 664 | } 665 | 666 | // 如果有错误,检查是否是取消操作 667 | if let errorDict = errorDict, let error = errorDict[NSAppleScript.errorNumber] as? NSNumber { 668 | let errorCode = error.intValue 669 | let errorMessage = errorDict[NSAppleScript.errorMessage] as? String ?? "未知错误" 670 | 671 | // -128 是用户取消操作的错误代码 672 | if errorCode == -128 { 673 | self.logger.info("用户取消了操作") 674 | return (false, "USER_CANCELED") 675 | } 676 | 677 | self.logger.error("错误\(errorCode): \(errorMessage)") 678 | return (false, "\(errorMessage) (\(errorCode))") 679 | } 680 | 681 | // 如果没有错误但也没有结果 682 | guard let descriptor = descriptor else { 683 | return (true, "") 684 | } 685 | 686 | // 返回结果 687 | let output = descriptor.stringValue ?? "" 688 | return (true, output) 689 | } 690 | 691 | // 运行终端命令 692 | func runTerminalCommand(_ command: String, log: String) { 693 | logger.info("\(log): \(command)") 694 | 695 | let script = """ 696 | do shell script "\(command)" 697 | """ 698 | 699 | let result = runAppleScript(script: script) 700 | appendToCommandOutput("\(log): \(command)") 701 | appendToCommandOutput("结果: \(result.output)") 702 | } 703 | 704 | // 添加命令输出到日志区域 705 | func appendToCommandOutput(_ text: String) { 706 | let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) 707 | commandOutput += "[\(timestamp)] \(text)\n" 708 | } 709 | } 710 | -------------------------------------------------------------------------------- /ShutdownScheduler/Item.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Item.swift 3 | // ShutdownScheduler 4 | // 5 | // Created by Hu Gang on 2025/5/7. 6 | // 7 | 8 | import Foundation 9 | import SwiftData 10 | 11 | enum ActionType: String, Codable { 12 | case shutdown = "关机" 13 | case sleep = "休眠" 14 | } 15 | 16 | @Model 17 | final class Item { 18 | var timestamp: Date // 执行时间 19 | var createdAt: Date // 创建时间 20 | var scheduledTime: Date // 计划时间 21 | var actionType: ActionType 22 | var minutes: Int // 设置的分钟数 23 | 24 | init(timestamp: Date = Date(), actionType: ActionType, minutes: Int, scheduledTime: Date) { 25 | self.timestamp = timestamp 26 | self.createdAt = Date() 27 | self.actionType = actionType 28 | self.minutes = minutes 29 | self.scheduledTime = scheduledTime 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ShutdownScheduler/SettingsManager.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ServiceManagement 3 | import os.log 4 | 5 | enum AppLanguage: String, CaseIterable { 6 | case auto = "自动" 7 | case english = "English" 8 | case simplifiedChinese = "简体中文" 9 | 10 | var localeIdentifier: String? { 11 | switch self { 12 | case .auto: 13 | return nil 14 | case .english: 15 | return "en" 16 | case .simplifiedChinese: 17 | return "zh-Hans" 18 | } 19 | } 20 | 21 | var displayName: String { 22 | return self.rawValue 23 | } 24 | } 25 | 26 | class SettingsManager { 27 | static let shared = SettingsManager() 28 | 29 | private let defaults = UserDefaults.standard 30 | private let languageKey = "AppLanguagePreference" 31 | private let launchAtLoginKey = "LaunchAtLogin" 32 | 33 | // 当前语言设置 34 | var currentLanguage: AppLanguage { 35 | get { 36 | if let savedValue = defaults.string(forKey: languageKey), 37 | let language = AppLanguage(rawValue: savedValue) { 38 | return language 39 | } 40 | return .auto 41 | } 42 | set { 43 | defaults.set(newValue.rawValue, forKey: languageKey) 44 | NotificationCenter.default.post(name: Notification.Name("LanguageChanged"), object: nil) 45 | } 46 | } 47 | 48 | // 开机启动设置 49 | var launchAtLogin: Bool { 50 | get { 51 | return defaults.bool(forKey: launchAtLoginKey) 52 | } 53 | set { 54 | defaults.set(newValue, forKey: launchAtLoginKey) 55 | updateLoginItemStatus(enabled: newValue) 56 | } 57 | } 58 | 59 | private init() { 60 | // 初始化默认值 61 | if defaults.object(forKey: languageKey) == nil { 62 | defaults.set(AppLanguage.auto.rawValue, forKey: languageKey) 63 | } 64 | 65 | if defaults.object(forKey: launchAtLoginKey) == nil { 66 | defaults.set(false, forKey: launchAtLoginKey) 67 | } 68 | } 69 | 70 | // 更新开机启动状态 71 | private func updateLoginItemStatus(enabled: Bool) { 72 | let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.app.ShutdownScheduler", category: "SettingsManager") 73 | 74 | // 使用现代 API 设置登录项 75 | if #available(macOS 13.0, *) { 76 | // macOS 13+ 使用 SMAppService 77 | let appService = SMAppService.mainApp 78 | do { 79 | if enabled { 80 | if appService.status == .enabled { 81 | try appService.unregister() 82 | } 83 | try appService.register() 84 | logger.info("成功添加到登录项") 85 | } else { 86 | if appService.status == .enabled { 87 | try appService.unregister() 88 | logger.info("成功从登录项移除") 89 | } 90 | } 91 | } catch { 92 | logger.error("设置登录项失败: \(error.localizedDescription)") 93 | } 94 | } else { 95 | // macOS 12 及更早版本 96 | logger.warning("当前系统版本不支持自动启动设置,需要手动添加到登录项") 97 | // 显示提示给用户,告知需要手动设置 98 | let notification = NSUserNotification() 99 | notification.title = "自动启动设置" 100 | notification.informativeText = "请在系统偏好设置 > 用户与群组 > 登录项中手动添加本应用" 101 | notification.soundName = NSUserNotificationDefaultSoundName 102 | NSUserNotificationCenter.default.deliver(notification) 103 | } 104 | } 105 | 106 | // 获取当前语言的本地化字符串 107 | func localizedString(for key: String, defaultValue: String) -> String { 108 | guard let languageCode = currentLanguage.localeIdentifier else { 109 | // 自动模式,使用系统语言 110 | return NSLocalizedString(key, comment: "") 111 | } 112 | 113 | // 指定语言 114 | let path = Bundle.main.path(forResource: languageCode, ofType: "lproj") 115 | if let path = path, let bundle = Bundle(path: path) { 116 | return bundle.localizedString(forKey: key, value: defaultValue, table: nil) 117 | } 118 | 119 | return defaultValue 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /ShutdownScheduler/SettingsWindow.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 87 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /ShutdownScheduler/SettingsWindowController.swift: -------------------------------------------------------------------------------- 1 | import Cocoa 2 | 3 | class SettingsWindowController: NSWindowController { 4 | 5 | // 语言选择下拉菜单 6 | @IBOutlet weak var languagePopup: NSPopUpButton! 7 | 8 | // 开机启动复选框 9 | @IBOutlet weak var launchAtLoginCheckbox: NSButton! 10 | 11 | // 标签文本 12 | @IBOutlet weak var languageLabel: NSTextField! 13 | @IBOutlet weak var launchAtLoginLabel: NSTextField! 14 | @IBOutlet weak var settingsTitleLabel: NSTextField! 15 | 16 | // 关闭按钮 17 | @IBOutlet weak var closeButton: NSButton! 18 | 19 | override var windowNibName: NSNib.Name? { 20 | return "SettingsWindow" 21 | } 22 | 23 | override func windowDidLoad() { 24 | super.windowDidLoad() 25 | 26 | // 设置窗口标题 27 | self.window?.title = "设置" 28 | 29 | // 初始化UI 30 | setupUI() 31 | 32 | // 加载当前设置 33 | loadSettings() 34 | 35 | // 注册语言变更通知 36 | NotificationCenter.default.addObserver(self, 37 | selector: #selector(handleLanguageChanged), 38 | name: Notification.Name("LanguageChanged"), 39 | object: nil) 40 | } 41 | 42 | deinit { 43 | NotificationCenter.default.removeObserver(self) 44 | } 45 | 46 | private func setupUI() { 47 | // 设置标题 48 | settingsTitleLabel.stringValue = "应用程序设置" 49 | 50 | // 设置语言标签 51 | languageLabel.stringValue = "界面语言:" 52 | 53 | // 设置语言选项 54 | languagePopup.removeAllItems() 55 | for language in AppLanguage.allCases { 56 | languagePopup.addItem(withTitle: language.displayName) 57 | } 58 | 59 | // 设置开机启动标签 60 | launchAtLoginLabel.stringValue = "开机自动启动:" 61 | 62 | // 设置窗口大小 63 | self.window?.setContentSize(NSSize(width: 350, height: 200)) 64 | 65 | // 更新UI文本 66 | updateUIText() 67 | } 68 | 69 | private func loadSettings() { 70 | // 加载语言设置 71 | if let index = AppLanguage.allCases.firstIndex(of: SettingsManager.shared.currentLanguage) { 72 | languagePopup.selectItem(at: index) 73 | } 74 | 75 | // 加载开机启动设置 76 | launchAtLoginCheckbox.state = SettingsManager.shared.launchAtLogin ? .on : .off 77 | } 78 | 79 | @objc private func handleLanguageChanged() { 80 | updateUIText() 81 | } 82 | 83 | private func updateUIText() { 84 | // 根据当前语言更新UI文本 85 | self.window?.title = getLocalizedString(for: "settings", defaultValue: "设置") 86 | settingsTitleLabel.stringValue = getLocalizedString(for: "app_settings", defaultValue: "应用程序设置") 87 | languageLabel.stringValue = getLocalizedString(for: "interface_language", defaultValue: "界面语言:") 88 | launchAtLoginLabel.stringValue = getLocalizedString(for: "launch_at_login", defaultValue: "开机自动启动:") 89 | 90 | // 更新关闭按钮文本 91 | if let closeButton = closeButton { 92 | closeButton.title = getLocalizedString(for: "close_button", defaultValue: "关闭") 93 | } 94 | } 95 | 96 | private func getLocalizedString(for key: String, defaultValue: String) -> String { 97 | return SettingsManager.shared.localizedString(for: key, defaultValue: defaultValue) 98 | } 99 | 100 | // MARK: - 事件处理 101 | 102 | @IBAction func languageChanged(_ sender: NSPopUpButton) { 103 | if let selectedLanguage = AppLanguage.allCases[safe: sender.indexOfSelectedItem] { 104 | SettingsManager.shared.currentLanguage = selectedLanguage 105 | } 106 | } 107 | 108 | @IBAction func launchAtLoginChanged(_ sender: NSButton) { 109 | SettingsManager.shared.launchAtLogin = (sender.state == .on) 110 | } 111 | 112 | @IBAction func closeButtonClicked(_ sender: Any) { 113 | self.window?.close() 114 | } 115 | } 116 | 117 | // 安全数组访问扩展 118 | extension Collection { 119 | subscript(safe index: Index) -> Element? { 120 | return indices.contains(index) ? self[index] : nil 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ShutdownScheduler/ShutdownScheduler.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ShutdownScheduler/ShutdownSchedulerApp.swift: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/ShutdownScheduler/ShutdownSchedulerApp.swift -------------------------------------------------------------------------------- /ShutdownScheduler/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* Settings Window */ 2 | "settings" = "Settings"; 3 | "app_settings" = "Application Settings"; 4 | "interface_language" = "Interface Language:"; 5 | "launch_at_login" = "Launch at Login:"; 6 | "close" = "Close"; 7 | 8 | /* Menu Items */ 9 | "shutdown_menu" = "Schedule Shutdown"; 10 | "sleep_menu" = "Schedule Sleep"; 11 | "cancel_menu" = "Cancel Schedule"; 12 | "settings_menu" = "Settings..."; 13 | "about_menu" = "About"; 14 | "quit_menu" = "Quit"; 15 | 16 | /* Notifications */ 17 | "cancel_notification_title" = "Schedule Cancelled"; 18 | "cancel_notification_text" = "Shutdown/Sleep schedule has been cancelled"; 19 | 20 | /* Main Interface */ 21 | "app_title" = "Shutdown/Sleep Timer"; 22 | "delay_minutes" = "Delay Minutes:"; 23 | "action_type" = "Action Type:"; 24 | "shutdown" = "Shutdown"; 25 | "sleep" = "Sleep"; 26 | "start_countdown" = "Start Countdown"; 27 | "cancel_task" = "Cancel Task"; 28 | "countdown" = "Countdown: %@"; 29 | "estimated_time" = "Estimated %@ time: %@"; 30 | "visit_github" = "Visit Github"; 31 | "close_button" = "Close"; 32 | -------------------------------------------------------------------------------- /ShutdownScheduler/main.swift: -------------------------------------------------------------------------------- 1 | // main.swift 2 | // 传统 AppDelegate 菜单栏应用入口 3 | import Cocoa 4 | 5 | _ = NSApplication.shared 6 | let delegate = AppDelegate() 7 | NSApplication.shared.delegate = delegate 8 | _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) 9 | -------------------------------------------------------------------------------- /ShutdownScheduler/zh-Hans.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 设置窗口 */ 2 | "settings" = "设置"; 3 | "app_settings" = "应用程序设置"; 4 | "interface_language" = "界面语言:"; 5 | "launch_at_login" = "开机自动启动:"; 6 | "close" = "关闭"; 7 | 8 | /* 菜单项 */ 9 | "shutdown_menu" = "定时关机"; 10 | "sleep_menu" = "定时休眠"; 11 | "cancel_menu" = "取消定时"; 12 | "settings_menu" = "设置..."; 13 | "about_menu" = "关于"; 14 | "quit_menu" = "退出"; 15 | 16 | /* 通知 */ 17 | "cancel_notification_title" = "定时已取消"; 18 | "cancel_notification_text" = "定时关机/休眠任务已取消"; 19 | 20 | /* 主界面 */ 21 | "app_title" = "定时关机/休眠工具"; 22 | "delay_minutes" = "延时分钟:"; 23 | "action_type" = "操作类型:"; 24 | "shutdown" = "关机"; 25 | "sleep" = "休眠"; 26 | "start_countdown" = "开始倒计时"; 27 | "cancel_task" = "取消任务"; 28 | "countdown" = "倒计时中: %@"; 29 | "estimated_time" = "预计%@时间: %@"; 30 | -------------------------------------------------------------------------------- /background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ihugang/ShutdownScheduler/c32c4be1c319d3a1710a55abeaca47fbbe4931d3/background.png -------------------------------------------------------------------------------- /build_shutdown_scheduler.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | APP_NAME="ShutdownScheduler" 6 | SCHEME="ShutdownScheduler" 7 | CONFIGURATION="Release" 8 | PROJECT="ShutdownScheduler.xcodeproj" 9 | ARCHIVE_PATH="build/${APP_NAME}.xcarchive" 10 | EXPORT_PATH="build/export" 11 | EXPORT_OPTIONS_PLIST="ExportOptions.plist" # 你需自定义或复用现有 12 | DMG_NAME="${APP_NAME}.dmg" 13 | CERT_ID="Developer ID Application: Hangzhou Gravity Cyberinfo Co.,Ltd (6X2HSWDZCR)" # 替换为你实际的证书名称 14 | NOTARY_PROFILE="AC_PASSWORD" # 需先用 `xcrun notarytool store-credentials` 配置好 15 | 16 | echo "🧹 Cleaning previous builds..." 17 | rm -rf build 18 | 19 | echo "🏗️ Archiving..." 20 | xcodebuild archive \ 21 | -project "${PROJECT}" \ 22 | -scheme "${SCHEME}" \ 23 | -configuration "${CONFIGURATION}" \ 24 | -archivePath "${ARCHIVE_PATH}" \ 25 | -destination 'generic/platform=macOS' \ 26 | SKIP_INSTALL=NO \ 27 | BUILD_LIBRARY_FOR_DISTRIBUTION=YES 28 | 29 | echo "📦 Exporting .app..." 30 | xcodebuild -exportArchive \ 31 | -archivePath "${ARCHIVE_PATH}" \ 32 | -exportPath "${EXPORT_PATH}" \ 33 | -exportOptionsPlist "${EXPORT_OPTIONS_PLIST}" 34 | 35 | echo "💿 Creating DMG..." 36 | create-dmg \ 37 | --volname "${APP_NAME}" \ 38 | --window-size 800 600 \ 39 | --background "background.png" \ 40 | --icon "${APP_NAME}.app" 200 250 \ 41 | --app-drop-link 600 250 \ 42 | "${DMG_NAME}" \ 43 | "${EXPORT_PATH}/" 44 | 45 | echo "🔏 Signing .app..." 46 | codesign --deep --force --verify --verbose \ 47 | --sign "$CERT_ID" "$EXPORT_PATH/$APP_NAME.app" 48 | 49 | echo "🔏 Signing .dmg..." 50 | codesign --force --sign "$CERT_ID" "$DMG_NAME" 51 | 52 | echo "📨 Submitting for notarization..." 53 | xcrun notarytool submit "$DMG_NAME" \ 54 | --keychain-profile "$NOTARY_PROFILE" \ 55 | --wait 56 | 57 | echo "📎 Stapling notarization ticket..." 58 | xcrun stapler staple "$DMG_NAME" 59 | 60 | echo "✅ Build, notarization and DMG creation completed." 61 | --------------------------------------------------------------------------------