├── .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 | 
4 | 
5 | 
6 | 
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 |
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 |
--------------------------------------------------------------------------------