From 95be039cafdbc8cf104e573dd606e86d56593115 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 6 Jul 2026 17:09:30 +0800 Subject: [PATCH] feat: add Crontab and Homebrew Services management Expose user/system crontab editing and brew services start/stop/restart in the sidebar, reusing PrivilegeService for system-level writes. Co-authored-by: Cursor --- CHANGELOG.md | 4 + LaunchManager/ContentView.swift | 65 +++- LaunchManager/Localizable.xcstrings | 310 ++++++++++++++++++ LaunchManager/Models/CronJob.swift | 116 +++++++ LaunchManager/Models/CrontabScope.swift | 62 ++++ LaunchManager/Models/HomebrewService.swift | 44 +++ .../Models/HomebrewServiceScope.swift | 29 ++ LaunchManager/Models/PendingOperation.swift | 2 + LaunchManager/Models/SidebarSelection.swift | 2 + LaunchManager/Services/BrewPathResolver.swift | 20 ++ .../Services/BrewServicesService.swift | 135 ++++++++ LaunchManager/Services/CrontabParser.swift | 206 ++++++++++++ LaunchManager/Services/CrontabService.swift | 109 ++++++ LaunchManager/Store/CrontabStore.swift | 104 ++++++ .../Store/HomebrewServiceStore.swift | 98 ++++++ LaunchManager/Views/CronListView.swift | 176 ++++++++++ LaunchManager/Views/CronRowView.swift | 141 ++++++++ LaunchManager/Views/EditCronSheet.swift | 280 ++++++++++++++++ .../Views/HomebrewServiceListView.swift | 123 +++++++ .../Views/HomebrewServiceRowView.swift | 145 ++++++++ LaunchManager/Views/SidebarView.swift | 24 ++ LaunchManagerTests/LaunchManagerTests.swift | 174 ++++++++++ 22 files changed, 2365 insertions(+), 4 deletions(-) create mode 100644 LaunchManager/Models/CronJob.swift create mode 100644 LaunchManager/Models/CrontabScope.swift create mode 100644 LaunchManager/Models/HomebrewService.swift create mode 100644 LaunchManager/Models/HomebrewServiceScope.swift create mode 100644 LaunchManager/Services/BrewPathResolver.swift create mode 100644 LaunchManager/Services/BrewServicesService.swift create mode 100644 LaunchManager/Services/CrontabParser.swift create mode 100644 LaunchManager/Services/CrontabService.swift create mode 100644 LaunchManager/Store/CrontabStore.swift create mode 100644 LaunchManager/Store/HomebrewServiceStore.swift create mode 100644 LaunchManager/Views/CronListView.swift create mode 100644 LaunchManager/Views/CronRowView.swift create mode 100644 LaunchManager/Views/EditCronSheet.swift create mode 100644 LaunchManager/Views/HomebrewServiceListView.swift create mode 100644 LaunchManager/Views/HomebrewServiceRowView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a9817f9..04e9420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Crontab** — browse, create, edit, enable/disable, and delete user and system (`/etc/crontab`) cron jobs from the sidebar (similar to Launch Agents). System writes use the same admin password prompt as system LaunchAgents. +- **Homebrew Services** — list, start, stop, and restart `brew services` for user and system scopes; system operations use the same `PrivilegeService` admin prompt as system LaunchAgents. + ## [1.7.0] - 2026-07-06 diff --git a/LaunchManager/ContentView.swift b/LaunchManager/ContentView.swift index 3888601..827dbfc 100644 --- a/LaunchManager/ContentView.swift +++ b/LaunchManager/ContentView.swift @@ -10,6 +10,8 @@ import SwiftUI struct ContentView: View { @StateObject private var store = AgentStore() + @StateObject private var crontabStore = CrontabStore() + @StateObject private var homebrewStore = HomebrewServiceStore() @StateObject private var serviceStore = ServiceStore() @StateObject private var updateChecker = UpdateChecker() @Environment(\.scenePhase) private var scenePhase @@ -17,6 +19,8 @@ struct ContentView: View { @State private var showingNewAgent = false @State private var showingNewFromXml = false @State private var newAgentScope: LaunchItem.Scope = .userAgent + @State private var showingNewCron = false + @State private var newCronScope: CrontabScope = .user @State private var serviceLaunchDraft: LaunchAgentDraft? @State private var searchText = "" @State private var errorMessage: String? @@ -25,6 +29,14 @@ struct ContentView: View { @State private var showAbout = false @State private var pendingUpdateCheck = false + private var isHomebrewView: Bool { + selection == .homebrew + } + + private var isCrontabView: Bool { + selection == .crontab + } + private var isLoginItemsGuide: Bool { selection == .loginItems } @@ -39,18 +51,25 @@ struct ContentView: View { var body: some View { NavigationSplitView { - SidebarView(selection: $selection, store: store) + SidebarView( + selection: $selection, + store: store, + crontabStore: crontabStore, + homebrewStore: homebrewStore + ) } detail: { detailView } .modifier(ConditionalSearchable( - isEnabled: isAgentsView && !isLoginItemsGuide && !isServicesView, + isEnabled: (isAgentsView || isCrontabView || isHomebrewView) && !isLoginItemsGuide && !isServicesView, text: $searchText, - prompt: "搜索 Label 或路径" + prompt: searchPrompt )) .onAppear { store.refresh() store.startWatching() + crontabStore.refresh() + homebrewStore.refresh() serviceStore.startPolling(isActive: scenePhase == .active) if !hasSeenOnboarding { showOnboarding = true @@ -70,11 +89,17 @@ struct ContentView: View { serviceStore.startPolling(isActive: phase == .active) if phase == .active { store.refresh() + crontabStore.refresh() + homebrewStore.refresh() } } .onChange(of: selection) { _, newSelection in if newSelection == .services { serviceStore.refreshNow() + } else if newSelection == .crontab { + crontabStore.refresh() + } else if newSelection == .homebrew { + homebrewStore.refresh() } } .sheet(isPresented: $showOnboarding) { @@ -117,6 +142,14 @@ struct ContentView: View { draft: draft ) } + .sheet(isPresented: $showingNewCron) { + EditCronSheet( + existingJob: nil, + scope: newCronScope, + store: crontabStore, + errorMessage: $errorMessage + ) + } .alert("错误", isPresented: Binding( get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } } @@ -153,6 +186,20 @@ struct ContentView: View { errorMessage: $errorMessage, onCreateLaunchAgent: { draft in serviceLaunchDraft = draft } ) + case .crontab: + CronListView( + store: crontabStore, + searchText: searchText, + newCronScope: $newCronScope, + showingNewCron: $showingNewCron, + errorMessage: $errorMessage + ) + case .homebrew: + HomebrewServiceListView( + store: homebrewStore, + searchText: searchText, + errorMessage: $errorMessage + ) case .loginItems: LoginItemsGuideView() case .agents, .none: @@ -166,9 +213,19 @@ struct ContentView: View { ) } } + + private var searchPrompt: LocalizedStringKey { + if isCrontabView { + return "搜索命令或计划" + } + if isHomebrewView { + return "搜索服务名或 Label" + } + return "搜索 Label 或路径" + } } -/// Applies `.searchable` only when listing launchd agents (hidden on Login Items guide). +/// Applies `.searchable` when listing launchd agents, crontab jobs, or Homebrew services. private struct ConditionalSearchable: ViewModifier { let isEnabled: Bool @Binding var text: String diff --git a/LaunchManager/Localizable.xcstrings b/LaunchManager/Localizable.xcstrings index 3c4c26e..cf7c5f4 100644 --- a/LaunchManager/Localizable.xcstrings +++ b/LaunchManager/Localizable.xcstrings @@ -204,6 +204,316 @@ } } }, + "Crontab" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crontab" + } + } + } + }, + "用户定时任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User scheduled jobs" + } + } + } + }, + "没有 Cron 任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No cron jobs" + } + } + } + }, + "用户 crontab 与 /etc/crontab 均为空,点击「新建」添加定时任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Both user crontab and /etc/crontab are empty. Click New to add a scheduled job." + } + } + } + }, + "用户 · 系统" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User · System" + } + } + } + }, + "用户 Crontab" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User Crontab" + } + } + } + }, + "系统 Crontab" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Crontab" + } + } + } + }, + "新建用户 Cron 任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New User Cron Job" + } + } + } + }, + "新建系统 Cron 任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New System Cron Job" + } + } + } + }, + "运行用户" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Run as user" + } + } + } + }, + "保存时将提示输入管理员密码,与系统 LaunchAgent 写入方式相同。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving will prompt for an administrator password, same as writing a system LaunchAgent." + } + } + } + }, + "写入时需管理员密码" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requires admin password to write" + } + } + } + }, + "文件头部" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File header" + } + } + } + }, + "搜索命令或计划" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search command or schedule" + } + } + } + }, + "新建 Cron 任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Cron Job" + } + } + } + }, + "编辑 Cron 任务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Edit Cron Job" + } + } + } + }, + "Crontab 头部" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crontab header" + } + } + } + }, + "Homebrew Services" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Homebrew Services" + } + } + } + }, + "用户服务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "User Services" + } + } + } + }, + "系统服务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System Services" + } + } + } + }, + "brew services · ~/Library/LaunchAgents" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "brew services · ~/Library/LaunchAgents" + } + } + } + }, + "sudo brew services · /Library/LaunchDaemons" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "sudo brew services · /Library/LaunchDaemons" + } + } + } + }, + "未找到 Homebrew" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Homebrew Not Found" + } + } + } + }, + "请安装 Homebrew,或确认 /opt/homebrew/bin/brew 可用。" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install Homebrew, or ensure /opt/homebrew/bin/brew is available." + } + } + } + }, + "没有 Homebrew 服务" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Homebrew services" + } + } + } + }, + "通过 brew install 安装带 service 的 formula 后,会显示在这里" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Install a formula with a service definition via brew install to see it here." + } + } + } + }, + "搜索服务名或 Label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search service name or label" + } + } + } + }, + "运行中" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Running" + } + } + } + }, + "未注册" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not registered" + } + } + } + }, + "启动/停止时需管理员密码" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Admin password required to start/stop" + } + } + } + }, + "重启中…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restarting…" + } + } + } + }, "Launchd(本应用管理)" : { "localizations" : { "en" : { diff --git a/LaunchManager/Models/CronJob.swift b/LaunchManager/Models/CronJob.swift new file mode 100644 index 0000000..b2fe8df --- /dev/null +++ b/LaunchManager/Models/CronJob.swift @@ -0,0 +1,116 @@ +import Foundation + +struct CronJob: Identifiable, Hashable { + let id: UUID + var scope: CrontabScope + var minute: String + var hour: String + var dayOfMonth: String + var month: String + var weekday: String + var runAsUser: String? + var command: String + var isEnabled: Bool + + init( + id: UUID = UUID(), + scope: CrontabScope = .user, + minute: String, + hour: String, + dayOfMonth: String, + month: String, + weekday: String, + runAsUser: String? = nil, + command: String, + isEnabled: Bool = true + ) { + self.id = id + self.scope = scope + self.minute = minute + self.hour = hour + self.dayOfMonth = dayOfMonth + self.month = month + self.weekday = weekday + self.runAsUser = runAsUser + self.command = command + self.isEnabled = isEnabled + } + + var scheduleFields: [String] { + [minute, hour, dayOfMonth, month, weekday] + } + + var scheduleDescription: String { + CronScheduleDescriber.describe( + minute: minute, + hour: hour, + dayOfMonth: dayOfMonth, + month: month, + weekday: weekday + ) + } +} + +enum CrontabLine: Identifiable, Hashable { + case blank(id: UUID) + case comment(id: UUID, text: String) + case environment(id: UUID, key: String, value: String) + case job(CronJob) + case raw(id: UUID, line: String) + + var id: UUID { + switch self { + case .blank(let id), .comment(let id, _), .environment(let id, _, _), .raw(let id, _): + return id + case .job(let job): + return job.id + } + } +} + +enum CronScheduleDescriber { + static func describe( + minute: String, + hour: String, + dayOfMonth: String, + month: String, + weekday: String + ) -> String { + let m = minute, h = hour, dom = dayOfMonth, mon = month, dow = weekday + + if m == "*" && h == "*" && dom == "*" && mon == "*" && dow == "*" { + return String(localized: "每分钟") + } + if m == "0" && h == "*" && dom == "*" && mon == "*" && dow == "*" { + return String(localized: "每小时整点") + } + if dom == "*" && mon == "*" && dow == "*" && h != "*" && m != "*" { + return String(localized: "每天 \(h):\(pad(m))") + } + if dom == "*" && mon == "*" && h != "*" && m != "*" && dow != "*" { + let day = weekdayName(dow) ?? "周\(dow)" + return String(localized: "每\(day) \(h):\(pad(m))") + } + if dow == "*" && mon == "*" && h != "*" && m != "*" && dom != "*" { + return String(localized: "每月 \(dom) 日 \(h):\(pad(m))") + } + return "\(m) \(h) \(dom) \(mon) \(dow)" + } + + private static func pad(_ value: String) -> String { + value.count == 1 ? "0\(value)" : value + } + + private static func weekdayName(_ value: String) -> String? { + switch value { + case "0", "7": return String(localized: "周日") + case "1": return String(localized: "周一") + case "2": return String(localized: "周二") + case "3": return String(localized: "周三") + case "4": return String(localized: "周四") + case "5": return String(localized: "周五") + case "6": return String(localized: "周六") + default: return nil + } + } +} diff --git a/LaunchManager/Models/CrontabScope.swift b/LaunchManager/Models/CrontabScope.swift new file mode 100644 index 0000000..a20e0de --- /dev/null +++ b/LaunchManager/Models/CrontabScope.swift @@ -0,0 +1,62 @@ +import Foundation + +enum CrontabScope: String, CaseIterable, Hashable { + case user + case system + + var displayName: String { + switch self { + case .user: return String(localized: "用户 Crontab") + case .system: return String(localized: "系统 Crontab") + } + } + + var sectionTitle: LocalizedStringKey { + switch self { + case .user: return "用户 Crontab" + case .system: return "系统 Crontab" + } + } + + var sectionSubtitle: LocalizedStringKey { + switch self { + case .user: return "crontab -l" + case .system: return "/etc/crontab" + } + } + + var systemImage: String { + switch self { + case .user: return "person.circle" + case .system: return "server.rack" + } + } + + var newJobMenuTitle: LocalizedStringKey { + switch self { + case .user: return "新建用户 Cron 任务" + case .system: return "新建系统 Cron 任务" + } + } + + var sourceDescription: String { + switch self { + case .user: return "crontab -l" + case .system: return "/etc/crontab" + } + } + + var requiresPrivilege: Bool { self == .system } + + var parserFormat: CrontabFormat { + switch self { + case .user: return .user + case .system: return .system + } + } +} + +enum CrontabFormat { + case user + case system +} diff --git a/LaunchManager/Models/HomebrewService.swift b/LaunchManager/Models/HomebrewService.swift new file mode 100644 index 0000000..a1b5498 --- /dev/null +++ b/LaunchManager/Models/HomebrewService.swift @@ -0,0 +1,44 @@ +import Foundation + +enum HomebrewServiceStatus: String, Hashable { + case started + case stopped + case none + case error + case unknown + + init(brewStatus: String) { + switch brewStatus { + case "started": self = .started + case "stopped": self = .stopped + case "none": self = .none + case "error": self = .error + default: self = .unknown + } + } + + var localizedName: String { + switch self { + case .started: return String(localized: "运行中") + case .stopped: return String(localized: "已停止") + case .none: return String(localized: "未注册") + case .error: return String(localized: "错误") + case .unknown: return String(localized: "未知") + } + } +} + +struct HomebrewService: Identifiable, Hashable { + var name: String + var scope: HomebrewServiceScope + var status: HomebrewServiceStatus + var runAsUser: String? + var plistPath: String? + var exitCode: Int? + + var id: String { "\(scope.rawValue):\(name)" } + var label: String { "homebrew.mxcl.\(name)" } + + var isRunning: Bool { status == .started } + var isRegistered: Bool { status == .started || status == .stopped } +} diff --git a/LaunchManager/Models/HomebrewServiceScope.swift b/LaunchManager/Models/HomebrewServiceScope.swift new file mode 100644 index 0000000..795f76e --- /dev/null +++ b/LaunchManager/Models/HomebrewServiceScope.swift @@ -0,0 +1,29 @@ +import Foundation + +enum HomebrewServiceScope: String, CaseIterable, Hashable { + case user + case root + + var sectionTitle: LocalizedStringKey { + switch self { + case .user: return "用户服务" + case .root: return "系统服务" + } + } + + var sectionSubtitle: LocalizedStringKey { + switch self { + case .user: return "brew services · ~/Library/LaunchAgents" + case .root: return "sudo brew services · /Library/LaunchDaemons" + } + } + + var systemImage: String { + switch self { + case .user: return "person.circle" + case .root: return "server.rack" + } + } + + var requiresPrivilege: Bool { self == .root } +} diff --git a/LaunchManager/Models/PendingOperation.swift b/LaunchManager/Models/PendingOperation.swift index 4e1bd5b..c5f6a15 100644 --- a/LaunchManager/Models/PendingOperation.swift +++ b/LaunchManager/Models/PendingOperation.swift @@ -6,6 +6,7 @@ enum PendingOperation: Equatable { case loading case unloading case enabling + case restarting var localizedLabel: LocalizedStringKey { switch self { @@ -14,6 +15,7 @@ enum PendingOperation: Equatable { case .loading: return "载入中…" case .unloading: return "移除中…" case .enabling: return "启用中…" + case .restarting: return "重启中…" } } } diff --git a/LaunchManager/Models/SidebarSelection.swift b/LaunchManager/Models/SidebarSelection.swift index 791c423..62975dd 100644 --- a/LaunchManager/Models/SidebarSelection.swift +++ b/LaunchManager/Models/SidebarSelection.swift @@ -2,6 +2,8 @@ import Foundation enum SidebarSelection: Hashable { case agents + case crontab + case homebrew case loginItems case services } diff --git a/LaunchManager/Services/BrewPathResolver.swift b/LaunchManager/Services/BrewPathResolver.swift new file mode 100644 index 0000000..531ae16 --- /dev/null +++ b/LaunchManager/Services/BrewPathResolver.swift @@ -0,0 +1,20 @@ +import Foundation + +enum BrewPathResolver { + static let candidatePaths = [ + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + ] + + static func resolve(using shell: ShellRunner = DefaultShellRunner()) -> String? { + for path in candidatePaths where FileManager.default.isExecutableFile(atPath: path) { + return path + } + if let output = try? shell.run("/usr/bin/which", arguments: ["brew"]).trimmingCharacters(in: .whitespacesAndNewlines), + !output.isEmpty, + FileManager.default.isExecutableFile(atPath: output) { + return output + } + return nil + } +} diff --git a/LaunchManager/Services/BrewServicesService.swift b/LaunchManager/Services/BrewServicesService.swift new file mode 100644 index 0000000..e546c59 --- /dev/null +++ b/LaunchManager/Services/BrewServicesService.swift @@ -0,0 +1,135 @@ +import Foundation + +struct BrewServicesService { + private let shell: ShellRunner + private let privilege: PrivilegeService + private let brewPath: String? + private let systemDaemonDirectory: String + + init( + shell: ShellRunner = DefaultShellRunner(), + privilege: PrivilegeService = PrivilegeService(), + brewPath: String? = BrewPathResolver.resolve(), + systemDaemonDirectory: String = "/Library/LaunchDaemons" + ) { + self.shell = shell + self.privilege = privilege + self.brewPath = brewPath + self.systemDaemonDirectory = systemDaemonDirectory + } + + var isBrewAvailable: Bool { brewPath != nil } + + func listServices(scope: HomebrewServiceScope) throws -> [HomebrewService] { + guard let brewPath else { return [] } + switch scope { + case .user: + return try listUserServices(brewPath: brewPath) + case .root: + return try listRootServices(brewPath: brewPath) + } + } + + func start(_ name: String, scope: HomebrewServiceScope) throws { + try runBrew(["services", "start", name], scope: scope) + } + + func stop(_ name: String, scope: HomebrewServiceScope) throws { + try runBrew(["services", "stop", name], scope: scope) + } + + func restart(_ name: String, scope: HomebrewServiceScope) throws { + try runBrew(["services", "restart", name], scope: scope) + } + + // MARK: - Private + + private struct BrewServiceRecord: Decodable { + let name: String + let status: String + let user: String? + let file: String? + let exit_code: Int? + } + + private func listUserServices(brewPath: String) throws -> [HomebrewService] { + let output = try shell.run(brewPath, arguments: ["services", "list", "--json"]) + return try decodeServices(from: output, scope: .user) + } + + private func listRootServices(brewPath: String) throws -> [HomebrewService] { + let output = try shell.run(brewPath, arguments: ["services", "list", "--json"]) + let installed = try decodeServices(from: output, scope: .user) + return installed.map { service in + let daemonPath = "\(systemDaemonDirectory)/\(service.label).plist" + let hasDaemon = FileManager.default.fileExists(atPath: daemonPath) + let status = hasDaemon ? statusForSystemLabel(service.label) : .none + return HomebrewService( + name: service.name, + scope: .root, + status: status, + runAsUser: "root", + plistPath: hasDaemon ? daemonPath : service.plistPath, + exitCode: service.exitCode + ) + } + } + + private func decodeServices(from output: String, scope: HomebrewServiceScope) throws -> [HomebrewService] { + let records = try JSONDecoder().decode([BrewServiceRecord].self, from: Data(output.utf8)) + return records.map { + HomebrewService( + name: $0.name, + scope: scope, + status: HomebrewServiceStatus(brewStatus: $0.status), + runAsUser: $0.user, + plistPath: $0.file, + exitCode: $0.exit_code + ) + } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + private func statusForSystemLabel(_ label: String) -> HomebrewServiceStatus { + guard let output = try? shell.run("/bin/launchctl", arguments: ["print", "system/\(label)"]) else { + return .none + } + if output.contains("Could not find service") { + return .none + } + if output.contains("state = running") { + return .started + } + if output.contains("state = not running") { + return .stopped + } + return .unknown + } + + private func runBrew(_ arguments: [String], scope: HomebrewServiceScope) throws { + guard let brewPath else { + throw BrewServicesError.brewNotFound + } + let command = ([brewPath] + arguments).map(shellQuote).joined(separator: " ") + if scope.requiresPrivilege { + try privilege.run(command) + } else { + _ = try shell.run(brewPath, arguments: arguments) + } + } + + private func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} + +enum BrewServicesError: LocalizedError { + case brewNotFound + + var errorDescription: String? { + switch self { + case .brewNotFound: + return String(localized: "未找到 Homebrew。请先安装 Homebrew 或确认 brew 在 PATH 中。") + } + } +} diff --git a/LaunchManager/Services/CrontabParser.swift b/LaunchManager/Services/CrontabParser.swift new file mode 100644 index 0000000..96060fa --- /dev/null +++ b/LaunchManager/Services/CrontabParser.swift @@ -0,0 +1,206 @@ +import Foundation + +struct CrontabParser { + private static let cronFieldPattern = #"^\S+$"# + private static let userFieldPattern = #"^[A-Za-z_][A-Za-z0-9_-]*$"# + + static let defaultSystemHeader = """ + # /etc/crontab: system-wide crontab + # minute\thour\tmday\tmonth\twday\twho\tcommand + """ + + static func parse(_ text: String, format: CrontabFormat = .user) -> [CrontabLine] { + let normalized = text.hasSuffix("\n") ? String(text.dropLast()) : text + guard !normalized.isEmpty else { return [] } + + return normalized + .split(separator: "\n", omittingEmptySubsequences: false) + .map { parseLine(String($0), format: format) } + } + + static func serialize(_ lines: [CrontabLine], format: CrontabFormat = .user) -> String { + let body = lines.map { serializeLine($0, format: format) }.joined(separator: "\n") + return body.isEmpty ? "" : body + "\n" + } + + static func jobs(from lines: [CrontabLine], scope: CrontabScope) -> [CronJob] { + lines.compactMap { line in + guard case .job(var job) = line else { return nil } + job.scope = scope + return job + } + } + + // MARK: - Private + + private static func parseLine(_ line: String, format: CrontabFormat) -> CrontabLine { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + return .blank(id: UUID()) + } + + if trimmed.hasPrefix("#") { + let remainder = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + if let fields = parseCronFields(remainder, format: format) { + return makeJobLine(fields: fields, format: format, isEnabled: false) + } + return .comment(id: UUID(), text: line) + } + + if format == .user, let env = parseEnvironment(trimmed) { + return .environment(id: UUID(), key: env.key, value: env.value) + } + + if let fields = parseCronFields(trimmed, format: format) { + return makeJobLine(fields: fields, format: format, isEnabled: true) + } + + return .raw(id: UUID(), line: line) + } + + private static func makeJobLine(fields: [String], format: CrontabFormat, isEnabled: Bool) -> CrontabLine { + switch format { + case .user: + return .job(CronJob( + scope: .user, + minute: fields[0], + hour: fields[1], + dayOfMonth: fields[2], + month: fields[3], + weekday: fields[4], + command: fields[5], + isEnabled: isEnabled + )) + case .system: + return .job(CronJob( + scope: .system, + minute: fields[0], + hour: fields[1], + dayOfMonth: fields[2], + month: fields[3], + weekday: fields[4], + runAsUser: fields[5], + command: fields[6], + isEnabled: isEnabled + )) + } + } + + private static func parseEnvironment(_ line: String) -> (key: String, value: String)? { + guard let equalsIndex = line.firstIndex(of: "=") else { return nil } + let key = String(line[.. [String]? { + let parts = splitPreservingCommand(line) + let minimumParts: Int + let scheduleFieldCount: Int + + switch format { + case .user: + minimumParts = 6 + scheduleFieldCount = 5 + case .system: + minimumParts = 7 + scheduleFieldCount = 6 + } + + guard parts.count >= minimumParts else { return nil } + + let scheduleParts = Array(parts.prefix(scheduleFieldCount)) + guard scheduleParts.prefix(5).allSatisfy({ + $0.range(of: cronFieldPattern, options: .regularExpression) != nil + }) else { + return nil + } + + if format == .system { + let user = scheduleParts[5] + guard user.range(of: userFieldPattern, options: .regularExpression) != nil else { + return nil + } + } + + let command = parts.dropFirst(scheduleFieldCount).joined(separator: " ") + guard !command.isEmpty else { return nil } + return scheduleParts + [command] + } + + private static func splitPreservingCommand(_ line: String) -> [String] { + var parts: [String] = [] + var current = "" + var inSingleQuote = false + var inDoubleQuote = false + var escaped = false + + for character in line { + if escaped { + current.append(character) + escaped = false + continue + } + + if character == "\\" { + escaped = true + current.append(character) + continue + } + + if character == "'" && !inDoubleQuote { + inSingleQuote.toggle() + current.append(character) + continue + } + + if character == "\"" && !inSingleQuote { + inDoubleQuote.toggle() + current.append(character) + continue + } + + if character.isWhitespace && !inSingleQuote && !inDoubleQuote { + if !current.isEmpty { + parts.append(current) + current = "" + } + continue + } + + current.append(character) + } + + if !current.isEmpty { + parts.append(current) + } + return parts + } + + private static func serializeLine(_ line: CrontabLine, format: CrontabFormat) -> String { + switch line { + case .blank: + return "" + case .comment(_, let text): + return text + case .environment(_, let key, let value): + return "\(key)=\(value)" + case .job(let job): + let schedule = job.scheduleFields.joined(separator: " ") + let body: String + switch format { + case .user: + body = "\(schedule) \(job.command)" + case .system: + let user = job.runAsUser ?? "root" + body = "\(schedule) \(user) \(job.command)" + } + return job.isEnabled ? body : "# \(body)" + case .raw(_, let text): + return text + } + } +} diff --git a/LaunchManager/Services/CrontabService.swift b/LaunchManager/Services/CrontabService.swift new file mode 100644 index 0000000..738b3be --- /dev/null +++ b/LaunchManager/Services/CrontabService.swift @@ -0,0 +1,109 @@ +import Foundation + +struct CrontabService { + static let systemCrontabPath = "/etc/crontab" + + private let shell: ShellRunner + private let privilege: PrivilegeService + var systemCrontabURL: URL + + init( + shell: ShellRunner = DefaultShellRunner(), + privilege: PrivilegeService = PrivilegeService(), + systemCrontabURL: URL = URL(fileURLWithPath: systemCrontabPath) + ) { + self.shell = shell + self.privilege = privilege + self.systemCrontabURL = systemCrontabURL + } + + func readUserCrontab() throws -> String { + do { + return try shell.run("/usr/bin/crontab", arguments: ["-l"]) + } catch ShellError.nonZeroExit(let code, let output) { + if code == 1 && output.localizedCaseInsensitiveContains("no crontab") { + return "" + } + throw ShellError.nonZeroExit(code: code, output: output) + } + } + + func readSystemCrontab() throws -> String { + guard FileManager.default.fileExists(atPath: systemCrontabURL.path) else { + return "" + } + return try String(contentsOf: systemCrontabURL, encoding: .utf8) ?? "" + } + + func writeUserCrontab(_ content: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/crontab") + process.arguments = ["-"] + + let stdinPipe = Pipe() + let errPipe = Pipe() + process.standardInput = stdinPipe + process.standardError = errPipe + process.standardOutput = FileHandle.nullDevice + + try process.run() + + let data = Data(content.utf8) + try stdinPipe.fileHandleForWriting.write(contentsOf: data) + try stdinPipe.fileHandleForWriting.close() + + process.waitUntilExit() + + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + let err = String(data: errData, encoding: .utf8) ?? "" + if process.terminationStatus != 0 { + throw ShellError.nonZeroExit(code: process.terminationStatus, output: err) + } + } + + func writeSystemCrontab(_ content: String, privilege: PrivilegeService? = nil) throws { + let auth = privilege ?? self.privilege + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("launchmanager-crontab-\(UUID().uuidString)") + try content.write(to: tmp, atomically: true, encoding: .utf8) + let dest = shellQuote(systemCrontabURL.path) + let src = shellQuote(tmp.path) + try auth.run("mv \(src) \(dest) && chown root:wheel \(dest) && chmod 644 \(dest)") + } + + func parseCrontab(scope: CrontabScope) throws -> [CrontabLine] { + let text: String + switch scope { + case .user: + text = try readUserCrontab() + case .system: + text = try readSystemCrontab() + } + return CrontabParser.parse(text, format: scope.parserFormat) + } + + func saveCrontab(lines: [CrontabLine], scope: CrontabScope, privilege: PrivilegeService? = nil) throws { + var content = CrontabParser.serialize(lines, format: scope.parserFormat) + if scope == .system { + if content.isEmpty { + content = CrontabParser.defaultSystemHeader + "\n" + } else if !FileManager.default.fileExists(atPath: systemCrontabURL.path), + !content.contains("# /etc/crontab") { + content = CrontabParser.defaultSystemHeader + "\n\n" + content + } + } + + switch scope { + case .user: + try writeUserCrontab(content) + case .system: + try writeSystemCrontab(content, privilege: privilege) + } + } + + // MARK: - Private + + private func shellQuote(_ path: String) -> String { + "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/LaunchManager/Store/CrontabStore.swift b/LaunchManager/Store/CrontabStore.swift new file mode 100644 index 0000000..032cb38 --- /dev/null +++ b/LaunchManager/Store/CrontabStore.swift @@ -0,0 +1,104 @@ +import Foundation + +@MainActor +final class CrontabStore: ObservableObject { + @Published private(set) var linesByScope: [CrontabScope: [CrontabLine]] = [:] + @Published private(set) var isRefreshing = false + + private let service: CrontabService + private let privilegeService: PrivilegeService + + init( + service: CrontabService = CrontabService(), + privilegeService: PrivilegeService = PrivilegeService() + ) { + self.service = service + self.privilegeService = privilegeService + } + + var jobs: [CronJob] { + CrontabScope.allCases.flatMap { jobs(for: $0) } + } + + func lines(for scope: CrontabScope) -> [CrontabLine] { + linesByScope[scope] ?? [] + } + + func jobs(for scope: CrontabScope) -> [CronJob] { + CrontabParser.jobs(from: lines(for: scope), scope: scope) + } + + func preambleLines(for scope: CrontabScope) -> [CrontabLine] { + lines(for: scope).filter { line in + switch line { + case .job: + return false + default: + return true + } + } + } + + func refresh() { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + var updated: [CrontabScope: [CrontabLine]] = [:] + for scope in CrontabScope.allCases { + do { + updated[scope] = try service.parseCrontab(scope: scope) + } catch { + updated[scope] = linesByScope[scope] ?? [] + } + } + linesByScope = updated + } + + func save(job: CronJob, replacing existingID: UUID?) throws { + var updated = lines(for: job.scope) + if let existingID, + let index = updated.firstIndex(where: { $0.id == existingID }) { + updated[index] = .job(job) + } else { + if !updated.isEmpty, case .blank = updated.last { + updated[updated.count - 1] = .job(job) + } else { + if !updated.isEmpty { + updated.append(.blank(id: UUID())) + } + updated.append(.job(job)) + } + } + try persist(updated, scope: job.scope) + } + + func deleteJob(id: UUID, scope: CrontabScope) throws { + var updated = lines(for: scope) + updated.removeAll { $0.id == id } + while updated.last.map({ if case .blank = $0 { return true } else { return false } }) == true { + updated.removeLast() + } + try persist(updated, scope: scope) + } + + func setEnabled(id: UUID, scope: CrontabScope, enabled: Bool) throws { + var updated = lines(for: scope) + guard let index = updated.firstIndex(where: { $0.id == id }), + case .job(var job) = updated[index] else { return } + job.isEnabled = enabled + updated[index] = .job(job) + try persist(updated, scope: scope) + } + + // MARK: - Private + + private func persist(_ updated: [CrontabLine], scope: CrontabScope) throws { + try service.saveCrontab( + lines: updated, + scope: scope, + privilege: scope.requiresPrivilege ? privilegeService : nil + ) + linesByScope[scope] = updated + } +} diff --git a/LaunchManager/Store/HomebrewServiceStore.swift b/LaunchManager/Store/HomebrewServiceStore.swift new file mode 100644 index 0000000..aaa62c6 --- /dev/null +++ b/LaunchManager/Store/HomebrewServiceStore.swift @@ -0,0 +1,98 @@ +import Foundation + +@MainActor +final class HomebrewServiceStore: ObservableObject { + @Published private(set) var servicesByScope: [HomebrewServiceScope: [HomebrewService]] = [:] + @Published private(set) var isRefreshing = false + @Published private(set) var brewAvailable = true + @Published var pendingOperations: [String: PendingOperation] = [:] + + private let service: BrewServicesService + + init(service: BrewServicesService = BrewServicesService()) { + self.service = service + self.brewAvailable = service.isBrewAvailable + } + + var services: [HomebrewService] { + HomebrewServiceScope.allCases.flatMap { services(for: $0) } + } + + func services(for scope: HomebrewServiceScope) -> [HomebrewService] { + servicesByScope[scope] ?? [] + } + + func refresh() { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + + brewAvailable = service.isBrewAvailable + guard brewAvailable else { + servicesByScope = [:] + return + } + + var updated: [HomebrewServiceScope: [HomebrewService]] = [:] + for scope in HomebrewServiceScope.allCases { + do { + updated[scope] = try service.listServices(scope: scope) + } catch { + updated[scope] = servicesByScope[scope] ?? [] + } + } + servicesByScope = updated + } + + func start(_ item: HomebrewService, onError: @escaping (String) -> Void = { _ in }) { + runPending(item, .starting, operationName: String(localized: "启动"), onError: onError) { + try self.service.start(item.name, scope: item.scope) + self.refresh() + } + } + + func stop(_ item: HomebrewService, onError: @escaping (String) -> Void = { _ in }) { + runPending(item, .stopping, operationName: String(localized: "停止"), onError: onError) { + try self.service.stop(item.name, scope: item.scope) + self.refresh() + } + } + + func restart(_ item: HomebrewService, onError: @escaping (String) -> Void = { _ in }) { + runPending(item, .restarting, operationName: String(localized: "重启"), onError: onError) { + try self.service.restart(item.name, scope: item.scope) + self.refresh() + } + } + + // MARK: - Private + + private func runPending( + _ item: HomebrewService, + _ operation: PendingOperation, + operationName: String, + onError: @escaping (String) -> Void, + work: @escaping () throws -> Void + ) { + guard pendingOperations[item.id] == nil else { return } + pendingOperations[item.id] = operation + Task { + defer { pendingOperations.removeValue(forKey: item.id) } + do { + try work() + } catch PrivilegeError.cancelled { + // user dismissed admin dialog + } catch let error as ShellError { + onError(brewErrorMessage(operation: operationName, detail: error.localizedDescription)) + } catch { + onError(brewErrorMessage(operation: operationName, detail: error.localizedDescription)) + } + } + } + + private func brewErrorMessage(operation: String, detail: String) -> String { + let trimmed = detail.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "\(operation)失败" } + return "\(operation)失败:\(trimmed)" + } +} diff --git a/LaunchManager/Views/CronListView.swift b/LaunchManager/Views/CronListView.swift new file mode 100644 index 0000000..bcc27d5 --- /dev/null +++ b/LaunchManager/Views/CronListView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +struct CronListView: View { + @ObservedObject var store: CrontabStore + var searchText: String + @Binding var newCronScope: CrontabScope + @Binding var showingNewCron: Bool + @Binding var errorMessage: String? + + @State private var collapsedGroups: Set = [] + + private var filteredJobsByScope: [CrontabScope: [CronJob]] { + Dictionary(uniqueKeysWithValues: CrontabScope.allCases.map { scope in + let jobs = store.jobs(for: scope) + guard !searchText.isEmpty else { return (scope, jobs) } + let filtered = jobs.filter { + $0.command.localizedCaseInsensitiveContains(searchText) || + $0.scheduleDescription.localizedCaseInsensitiveContains(searchText) || + ($0.runAsUser?.localizedCaseInsensitiveContains(searchText) ?? false) + } + return (scope, filtered) + }) + } + + private var groupedScopes: [CrontabScope] { + CrontabScope.allCases.filter { scope in + !(filteredJobsByScope[scope] ?? []).isEmpty || !store.preambleLines(for: scope).isEmpty + } + } + + private var hasVisibleContent: Bool { + !groupedScopes.isEmpty + } + + var body: some View { + Group { + if !hasVisibleContent { + ContentUnavailableView( + emptyTitle, + systemImage: "clock", + description: Text(emptyDescription) + ) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(groupedScopes, id: \.self) { scope in + cronGroupSection(scope: scope) + } + } + .padding() + } + } + } + .navigationTitle("Crontab") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + ForEach(CrontabScope.allCases, id: \.self) { scope in + Button { + newCronScope = scope + showingNewCron = true + } label: { + Label(scope.newJobMenuTitle, systemImage: scope.systemImage) + } + } + } label: { + Label("新建", systemImage: "plus") + } + } + ToolbarItem { + Button { store.refresh() } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .disabled(store.isRefreshing) + } + } + } + + private func cronGroupSection(scope: CrontabScope) -> some View { + let jobs = filteredJobsByScope[scope] ?? [] + let preamble = store.preambleLines(for: scope) + let count = jobs.count + let isCollapsed = collapsedGroups.contains(scope) + + return VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + if isCollapsed { + collapsedGroups.remove(scope) + } else { + collapsedGroups.insert(scope) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: scope.systemImage) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(scope.sectionTitle) + .font(.headline) + Text(scope.sectionSubtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text("\(count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color(nsColor: .controlBackgroundColor))) + Spacer() + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .foregroundStyle(.secondary) + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if !isCollapsed { + if !preamble.isEmpty { + preambleSection(scope: scope, lines: preamble) + } + ForEach(jobs) { job in + CronRowView(job: job, store: store, errorMessage: $errorMessage) + } + } + } + } + + private func preambleSection(scope: CrontabScope, lines: [CrontabLine]) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(scope == .system ? "文件头部" : "Crontab 头部") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(lines) { line in + Text(preambleText(for: line)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + } + + private func preambleText(for line: CrontabLine) -> String { + switch line { + case .blank: + return "" + case .comment(_, let text): + return text + case .environment(_, let key, let value): + return "\(key)=\(value)" + case .job: + return "" + case .raw(_, let text): + return text + } + } + + private var emptyTitle: LocalizedStringKey { + searchText.isEmpty ? "没有 Cron 任务" : "没有匹配结果" + } + + private var emptyDescription: LocalizedStringKey { + searchText.isEmpty + ? "用户 crontab 与 /etc/crontab 均为空,点击「新建」添加定时任务" + : "尝试其他搜索词" + } +} diff --git a/LaunchManager/Views/CronRowView.swift b/LaunchManager/Views/CronRowView.swift new file mode 100644 index 0000000..20e19de --- /dev/null +++ b/LaunchManager/Views/CronRowView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct CronRowView: View { + let job: CronJob + @ObservedObject var store: CrontabStore + @Binding var errorMessage: String? + + @State private var isExpanded = false + @State private var showingEdit = false + @State private var showingDeleteConfirm = false + + private var statusColor: Color { + job.isEnabled ? .green : .orange + } + + private var statusTooltip: LocalizedStringKey { + job.isEnabled ? "已启用" : "已禁用" + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .help(statusTooltip) + if job.scope == .system, let user = job.runAsUser { + Text(user) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(minWidth: 36, alignment: .leading) + } + Text(job.command) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + primaryActionButton + Button { showingEdit = true } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + Button(role: .destructive) { + showingDeleteConfirm = true + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + Button { + withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if isExpanded { + Divider() + VStack(alignment: .leading, spacing: 4) { + detailRow("计划", job.scheduleDescription) + detailRow("表达式", expressionText) + if let user = job.runAsUser { + detailRow("用户", user) + } + detailRow("命令", FilePathNormalizer.display(job.command)) + detailRow("来源", job.scope.sourceDescription) + if job.scope.requiresPrivilege { + detailRow("权限", String(localized: "写入时需管理员密码")) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .windowBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 2, y: 1) + ) + .sheet(isPresented: $showingEdit) { + EditCronSheet(existingJob: job, scope: job.scope, store: store, errorMessage: $errorMessage) + } + .confirmationDialog( + String(localized: "确认删除此 Cron 任务?"), + isPresented: $showingDeleteConfirm, + titleVisibility: .visible + ) { + Button("删除", role: .destructive) { + do { try store.deleteJob(id: job.id, scope: job.scope) } + catch PrivilegeError.cancelled { } + catch { errorMessage = error.localizedDescription } + } + } message: { + Text(job.command) + } + } + + private var expressionText: String { + if let user = job.runAsUser { + return (job.scheduleFields + [user]).joined(separator: " ") + } + return job.scheduleFields.joined(separator: " ") + } + + @ViewBuilder + private var primaryActionButton: some View { + if job.isEnabled { + Button("禁用") { + do { try store.setEnabled(id: job.id, scope: job.scope, enabled: false) } + catch PrivilegeError.cancelled { } + catch { errorMessage = error.localizedDescription } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Button("启用") { + do { try store.setEnabled(id: job.id, scope: job.scope, enabled: true) } + catch PrivilegeError.cancelled { } + catch { errorMessage = error.localizedDescription } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + + private func detailRow(_ label: LocalizedStringKey, _ value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .foregroundStyle(.secondary) + .frame(width: 48, alignment: .leading) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .font(.caption) + } +} diff --git a/LaunchManager/Views/EditCronSheet.swift b/LaunchManager/Views/EditCronSheet.swift new file mode 100644 index 0000000..5c5f6c8 --- /dev/null +++ b/LaunchManager/Views/EditCronSheet.swift @@ -0,0 +1,280 @@ +import SwiftUI + +struct EditCronSheet: View { + let existingJob: CronJob? + let scope: CrontabScope + @ObservedObject var store: CrontabStore + @Binding var errorMessage: String? + @Environment(\.dismiss) private var dismiss + + @State private var preset: SchedulePreset + @State private var minute: String + @State private var hour: String + @State private var dayOfMonth: String + @State private var month: String + @State private var weekday: String + @State private var runAsUser: String + @State private var command: String + @State private var isEnabled: Bool + + init( + existingJob: CronJob?, + scope: CrontabScope, + store: CrontabStore, + errorMessage: Binding + ) { + self.existingJob = existingJob + self.scope = existingJob?.scope ?? scope + self.store = store + _errorMessage = errorMessage + + let job = existingJob + _minute = State(initialValue: job?.minute ?? "0") + _hour = State(initialValue: job?.hour ?? "8") + _dayOfMonth = State(initialValue: job?.dayOfMonth ?? "*") + _month = State(initialValue: job?.month ?? "*") + _weekday = State(initialValue: job?.weekday ?? "*") + _runAsUser = State(initialValue: job?.runAsUser ?? NSUserName()) + _command = State(initialValue: job?.command ?? "") + _isEnabled = State(initialValue: job?.isEnabled ?? true) + _preset = State(initialValue: SchedulePreset.detect( + minute: job?.minute ?? "0", + hour: job?.hour ?? "8", + dayOfMonth: job?.dayOfMonth ?? "*", + month: job?.month ?? "*", + weekday: job?.weekday ?? "*" + )) + } + + var body: some View { + Form { + Section("计划") { + Picker("频率", selection: $preset) { + ForEach(SchedulePreset.allCases, id: \.self) { value in + Text(value.title).tag(value) + } + } + .onChange(of: preset) { _, newValue in + applyPreset(newValue) + } + + switch preset { + case .everyMinute: + EmptyView() + case .hourly: + StepperField(title: "分钟", value: $minute, range: 0...59) + case .daily: + StepperField(title: "小时", value: $hour, range: 0...23) + StepperField(title: "分钟", value: $minute, range: 0...59) + case .weekly: + Picker("星期", selection: $weekday) { + ForEach(WeekdayOption.allCases) { option in + Text(option.title).tag(option.value) + } + } + StepperField(title: "小时", value: $hour, range: 0...23) + StepperField(title: "分钟", value: $minute, range: 0...59) + case .monthly: + StepperField(title: "日期", value: $dayOfMonth, range: 1...31) + StepperField(title: "小时", value: $hour, range: 0...23) + StepperField(title: "分钟", value: $minute, range: 0...59) + case .custom: + TextField("分钟", text: $minute) + TextField("小时", text: $hour) + TextField("日", text: $dayOfMonth) + TextField("月", text: $month) + TextField("星期", text: $weekday) + } + + Text(previewSchedule) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("命令") { + if scope == .system { + TextField("运行用户", text: $runAsUser) + .font(.system(.body, design: .monospaced)) + } + TextField("要执行的命令", text: $command, axis: .vertical) + .lineLimit(2...6) + .font(.system(.body, design: .monospaced)) + Toggle("启用", isOn: $isEnabled) + } + + if scope.requiresPrivilege { + Section { + Text("保存时将提示输入管理员密码,与系统 LaunchAgent 写入方式相同。") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .formStyle(.grouped) + .frame(minWidth: 460, minHeight: 360) + .navigationTitle(existingJob == nil ? "新建 Cron 任务" : "编辑 Cron 任务") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { save() } + .disabled(!canSave) + } + } + } + + private var canSave: Bool { + let hasUser = scope == .user || !runAsUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return hasUser && + !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + [minute, hour, dayOfMonth, month, weekday].allSatisfy { !$0.isEmpty } + } + + private var previewSchedule: String { + CronScheduleDescriber.describe( + minute: minute, + hour: hour, + dayOfMonth: dayOfMonth, + month: month, + weekday: weekday + ) + } + + private func applyPreset(_ preset: SchedulePreset) { + switch preset { + case .everyMinute: + minute = "*"; hour = "*"; dayOfMonth = "*"; month = "*"; weekday = "*" + case .hourly: + hour = "*"; dayOfMonth = "*"; month = "*"; weekday = "*" + if minute == "*" { minute = "0" } + case .daily: + dayOfMonth = "*"; month = "*"; weekday = "*" + if hour == "*" { hour = "8" } + if minute == "*" { minute = "0" } + case .weekly: + dayOfMonth = "*"; month = "*" + if weekday == "*" { weekday = "1" } + if hour == "*" { hour = "8" } + if minute == "*" { minute = "0" } + case .monthly: + month = "*"; weekday = "*" + if dayOfMonth == "*" { dayOfMonth = "1" } + if hour == "*" { hour = "8" } + if minute == "*" { minute = "0" } + case .custom: + break + } + } + + private func save() { + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedUser = runAsUser.trimmingCharacters(in: .whitespacesAndNewlines) + let job = CronJob( + id: existingJob?.id ?? UUID(), + scope: scope, + minute: minute, + hour: hour, + dayOfMonth: dayOfMonth, + month: month, + weekday: weekday, + runAsUser: scope == .system ? trimmedUser : nil, + command: trimmedCommand, + isEnabled: isEnabled + ) + do { + try store.save(job: job, replacing: existingJob?.id) + dismiss() + } catch PrivilegeError.cancelled { + // user dismissed admin dialog + } catch { + errorMessage = error.localizedDescription + } + } +} + +private enum SchedulePreset: CaseIterable { + case everyMinute + case hourly + case daily + case weekly + case monthly + case custom + + var title: LocalizedStringKey { + switch self { + case .everyMinute: return "每分钟" + case .hourly: return "每小时" + case .daily: return "每天" + case .weekly: return "每周" + case .monthly: return "每月" + case .custom: return "自定义" + } + } + + static func detect( + minute: String, + hour: String, + dayOfMonth: String, + month: String, + weekday: String + ) -> SchedulePreset { + if minute == "*" && hour == "*" && dayOfMonth == "*" && month == "*" && weekday == "*" { + return .everyMinute + } + if hour == "*" && dayOfMonth == "*" && month == "*" && weekday == "*" { + return .hourly + } + if dayOfMonth == "*" && month == "*" && weekday == "*" { + return .daily + } + if dayOfMonth == "*" && month == "*" && weekday != "*" { + return .weekly + } + if month == "*" && weekday == "*" && dayOfMonth != "*" { + return .monthly + } + return .custom + } +} + +private struct WeekdayOption: Identifiable { + let value: String + let title: LocalizedStringKey + var id: String { value } + + static let allCases: [WeekdayOption] = [ + WeekdayOption(value: "1", title: "周一"), + WeekdayOption(value: "2", title: "周二"), + WeekdayOption(value: "3", title: "周三"), + WeekdayOption(value: "4", title: "周四"), + WeekdayOption(value: "5", title: "周五"), + WeekdayOption(value: "6", title: "周六"), + WeekdayOption(value: "0", title: "周日"), + ] +} + +private struct StepperField: View { + let title: LocalizedStringKey + @Binding var value: String + let range: ClosedRange + + private var intValue: Int { + Int(value) ?? range.lowerBound + } + + var body: some View { + Stepper(value: Binding( + get: { intValue }, + set: { value = String($0) } + ), in: range) { + HStack { + Text(title) + Spacer() + Text(value) + .monospacedDigit() + .foregroundStyle(.secondary) + } + } + } +} diff --git a/LaunchManager/Views/HomebrewServiceListView.swift b/LaunchManager/Views/HomebrewServiceListView.swift new file mode 100644 index 0000000..0a865c1 --- /dev/null +++ b/LaunchManager/Views/HomebrewServiceListView.swift @@ -0,0 +1,123 @@ +import SwiftUI + +struct HomebrewServiceListView: View { + @ObservedObject var store: HomebrewServiceStore + var searchText: String + @Binding var errorMessage: String? + + @State private var collapsedGroups: Set = [] + + private var filteredServicesByScope: [HomebrewServiceScope: [HomebrewService]] { + Dictionary(uniqueKeysWithValues: HomebrewServiceScope.allCases.map { scope in + let services = store.services(for: scope) + guard !searchText.isEmpty else { return (scope, services) } + let filtered = services.filter { + $0.name.localizedCaseInsensitiveContains(searchText) || + $0.label.localizedCaseInsensitiveContains(searchText) || + ($0.plistPath?.localizedCaseInsensitiveContains(searchText) ?? false) + } + return (scope, filtered) + }) + } + + private var groupedScopes: [HomebrewServiceScope] { + HomebrewServiceScope.allCases.filter { scope in + !(filteredServicesByScope[scope] ?? []).isEmpty + } + } + + var body: some View { + Group { + if !store.brewAvailable { + ContentUnavailableView( + "未找到 Homebrew", + systemImage: "exclamationmark.triangle", + description: Text("请安装 Homebrew,或确认 /opt/homebrew/bin/brew 可用。") + ) + } else if groupedScopes.isEmpty { + ContentUnavailableView( + emptyTitle, + systemImage: "mug", + description: Text(emptyDescription) + ) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(groupedScopes, id: \.self) { scope in + serviceGroupSection(scope: scope) + } + } + .padding() + } + } + } + .navigationTitle("Homebrew Services") + .toolbar { + ToolbarItem { + Button { store.refresh() } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .disabled(store.isRefreshing) + } + } + } + + private func serviceGroupSection(scope: HomebrewServiceScope) -> some View { + let services = filteredServicesByScope[scope] ?? [] + let count = services.count + let isCollapsed = collapsedGroups.contains(scope) + + return VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + if isCollapsed { + collapsedGroups.remove(scope) + } else { + collapsedGroups.insert(scope) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: scope.systemImage) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(scope.sectionTitle) + .font(.headline) + Text(scope.sectionSubtitle) + .font(.caption2) + .foregroundStyle(.secondary) + } + Text("\(count)") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color(nsColor: .controlBackgroundColor))) + Spacer() + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .foregroundStyle(.secondary) + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if !isCollapsed { + ForEach(services) { service in + HomebrewServiceRowView(service: service, store: store, errorMessage: $errorMessage) + } + } + } + } + + private var emptyTitle: LocalizedStringKey { + searchText.isEmpty ? "没有 Homebrew 服务" : "没有匹配结果" + } + + private var emptyDescription: LocalizedStringKey { + searchText.isEmpty + ? "通过 brew install 安装带 service 的 formula 后,会显示在这里" + : "尝试其他搜索词" + } +} diff --git a/LaunchManager/Views/HomebrewServiceRowView.swift b/LaunchManager/Views/HomebrewServiceRowView.swift new file mode 100644 index 0000000..511fa1f --- /dev/null +++ b/LaunchManager/Views/HomebrewServiceRowView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct HomebrewServiceRowView: View { + let service: HomebrewService + @ObservedObject var store: HomebrewServiceStore + @Binding var errorMessage: String? + + @State private var isExpanded = false + @State private var pulseOpacity = false + + private var pending: PendingOperation? { + store.pendingOperations[service.id] + } + + private var isRowLocked: Bool { + pending != nil + } + + private var statusColor: Color { + if pending != nil { return .yellow } + switch service.status { + case .started: return .green + case .stopped: return .orange + case .error: return .red + case .none: return Color(nsColor: .tertiaryLabelColor) + case .unknown: return .yellow + } + } + + private var statusTooltip: String { + if let exitCode = service.exitCode, service.status == .error { + return "\(service.status.localizedName) (退出码 \(exitCode))" + } + return service.status.localizedName + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + .opacity(isRowLocked ? (pulseOpacity ? 1.0 : 0.35) : 1.0) + .animation( + isRowLocked ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, + value: pulseOpacity + ) + .onAppear { pulseOpacity = true } + .onChange(of: isRowLocked) { _, locked in + pulseOpacity = locked + } + .help(Text(statusTooltip)) + Text(service.name) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + Spacer() + primaryActionButton + Button { + withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() } + } label: { + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .disabled(isRowLocked) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if isExpanded { + Divider() + VStack(alignment: .leading, spacing: 4) { + detailRow("状态", service.status.localizedName) + detailRow("Label", service.label) + if let user = service.runAsUser { + detailRow("用户", user) + } + if let path = service.plistPath { + detailRow("Plist", FilePathNormalizer.display(path)) + } + if service.scope.requiresPrivilege { + detailRow("权限", String(localized: "启动/停止时需管理员密码")) + } + HStack(spacing: 8) { + if service.isRunning { + Button("重启") { + store.restart(service) { errorMessage = $0 } + } + .buttonStyle(.bordered).controlSize(.small) + .disabled(isRowLocked) + } + } + .padding(.top, 4) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(nsColor: .windowBackgroundColor)) + .shadow(color: .black.opacity(0.06), radius: 2, y: 1) + ) + } + + @ViewBuilder + private var primaryActionButton: some View { + if let pending { + Button {} label: { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text(pending.localizedLabel) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(true) + } else if service.isRunning { + Button("停止") { + store.stop(service) { errorMessage = $0 } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Button("启动") { + store.start(service) { errorMessage = $0 } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + + private func detailRow(_ label: LocalizedStringKey, _ value: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(label) + .foregroundStyle(.secondary) + .frame(width: 48, alignment: .leading) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + .font(.caption) + } +} diff --git a/LaunchManager/Views/SidebarView.swift b/LaunchManager/Views/SidebarView.swift index fcfbcd8..eb3b139 100644 --- a/LaunchManager/Views/SidebarView.swift +++ b/LaunchManager/Views/SidebarView.swift @@ -3,6 +3,8 @@ import SwiftUI struct SidebarView: View { @Binding var selection: SidebarSelection? @ObservedObject var store: AgentStore + @ObservedObject var crontabStore: CrontabStore + @ObservedObject var homebrewStore: HomebrewServiceStore private var agentCount: Int { store.items.count + store.invalidItems.count @@ -21,6 +23,28 @@ struct SidebarView: View { ) } + Section { + SidebarRowButton( + selection: $selection, + tag: .crontab, + title: Text("Crontab"), + subtitle: Text("用户 · 系统"), + icon: "clock", + badge: crontabStore.jobs.count + ) + } + + Section { + SidebarRowButton( + selection: $selection, + tag: .homebrew, + title: Text("Homebrew Services"), + subtitle: Text("用户 · 系统"), + icon: "mug.fill", + badge: homebrewStore.services.count + ) + } + Section { SidebarRowButton( selection: $selection, diff --git a/LaunchManagerTests/LaunchManagerTests.swift b/LaunchManagerTests/LaunchManagerTests.swift index 5207d86..cbf98e1 100644 --- a/LaunchManagerTests/LaunchManagerTests.swift +++ b/LaunchManagerTests/LaunchManagerTests.swift @@ -1146,3 +1146,177 @@ final class DevServiceFilterTests: XCTestCase { ) } } + +// MARK: - CrontabParser Tests + +final class CrontabParserTests: XCTestCase { + func test_parseEnabledJob() { + let lines = CrontabParser.parse("0 8 * * * /usr/bin/date\n") + XCTAssertEqual(lines.count, 1) + guard case .job(let job) = lines[0] else { + return XCTFail("Expected job line") + } + XCTAssertEqual(job.minute, "0") + XCTAssertEqual(job.hour, "8") + XCTAssertEqual(job.command, "/usr/bin/date") + XCTAssertTrue(job.isEnabled) + } + + func test_parseDisabledJob() { + let lines = CrontabParser.parse("# 15 9 * * 1 /usr/bin/backup.sh\n") + guard case .job(let job) = lines[0] else { + return XCTFail("Expected job line") + } + XCTAssertEqual(job.minute, "15") + XCTAssertEqual(job.hour, "9") + XCTAssertEqual(job.weekday, "1") + XCTAssertEqual(job.command, "/usr/bin/backup.sh") + XCTAssertFalse(job.isEnabled) + } + + func test_parseEnvironmentAndComment() { + let text = """ + # backup jobs + MAILTO=ops@example.com + PATH=/usr/bin:/bin + + 0 2 * * * /usr/local/bin/backup.sh + """ + let lines = CrontabParser.parse(text) + XCTAssertEqual(lines.count, 5) + guard case .comment = lines[0] else { return XCTFail("Expected comment") } + guard case .environment(_, let key, let value) = lines[1] else { return XCTFail("Expected env") } + XCTAssertEqual(key, "MAILTO") + XCTAssertEqual(value, "ops@example.com") + guard case .blank = lines[3] else { return XCTFail("Expected blank line") } + guard case .job(let job) = lines[4] else { return XCTFail("Expected job") } + XCTAssertEqual(job.command, "/usr/local/bin/backup.sh") + } + + func test_serializeRoundTrip() { + let original = """ + MAILTO=me@example.com + 0 8 * * * /usr/bin/date + # 30 9 * * * /usr/bin/disabled + """ + let lines = CrontabParser.parse(original) + let serialized = CrontabParser.serialize(lines) + let reparsed = CrontabParser.parse(serialized) + XCTAssertEqual(CrontabParser.jobs(from: lines, scope: .user).count, 2) + XCTAssertEqual(CrontabParser.jobs(from: reparsed, scope: .user).map(\.command), ["/usr/bin/date", "/usr/bin/disabled"]) + XCTAssertEqual(CrontabParser.jobs(from: reparsed, scope: .user).map(\.isEnabled), [true, false]) + } + + func test_parseCommandWithSpacesInQuotes() { + let lines = CrontabParser.parse("0 9 * * * /bin/echo \"hello world\"\n") + guard case .job(let job) = lines[0] else { + return XCTFail("Expected job line") + } + XCTAssertEqual(job.command, "/bin/echo \"hello world\"") + } + + func test_parseSystemJob() { + let lines = CrontabParser.parse("*/5 * * * * root /usr/libexec/atrun\n", format: .system) + guard case .job(let job) = lines[0] else { + return XCTFail("Expected job line") + } + XCTAssertEqual(job.minute, "*/5") + XCTAssertEqual(job.runAsUser, "root") + XCTAssertEqual(job.command, "/usr/libexec/atrun") + XCTAssertTrue(job.isEnabled) + } + + func test_parseDisabledSystemJob() { + let lines = CrontabParser.parse("# 0 2 * * * daemon /usr/sbin/periodic daily\n", format: .system) + guard case .job(let job) = lines[0] else { + return XCTFail("Expected job line") + } + XCTAssertEqual(job.runAsUser, "daemon") + XCTAssertEqual(job.command, "/usr/sbin/periodic daily") + XCTAssertFalse(job.isEnabled) + } + + func test_serializeSystemJob() { + let job = CronJob( + scope: .system, + minute: "0", + hour: "3", + dayOfMonth: "*", + month: "*", + weekday: "*", + runAsUser: "root", + command: "/usr/bin/find /tmp -mtime +7 -delete" + ) + let serialized = CrontabParser.serialize([.job(job)], format: .system) + XCTAssertEqual(serialized, "0 3 * * * root /usr/bin/find /tmp -mtime +7 -delete\n") + } +} + +// MARK: - BrewServicesService Tests + +final class BrewServicesServiceTests: XCTestCase { + private struct MockShell: ShellRunner { + let brewJSON: String + let launchctlOutputs: [String: String] + + func run(_ path: String, arguments: [String]) throws -> String { + if path.hasSuffix("/brew"), arguments.first == "services" { + return brewJSON + } + if path.hasSuffix("/launchctl"), arguments.first == "print", arguments.count == 2 { + return launchctlOutputs[arguments[1]] ?? "Could not find service" + } + return "" + } + } + + func test_listUserServices_parsesJSON() throws { + let json = """ + [{"name":"colima","status":"started","user":"sean","file":"/opt/homebrew/opt/colima/homebrew.mxcl.colima.plist","exit_code":0}] + """ + let service = BrewServicesService( + shell: MockShell(brewJSON: json, launchctlOutputs: [:]), + brewPath: "/opt/homebrew/bin/brew" + ) + let items = try service.listServices(scope: .user) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].name, "colima") + XCTAssertEqual(items[0].status, .started) + XCTAssertEqual(items[0].label, "homebrew.mxcl.colima") + } + + func test_listRootServices_usesSystemLaunchctlStatus() throws { + let json = """ + [{"name":"nginx","status":"none","user":null,"file":"/opt/homebrew/opt/nginx/homebrew.mxcl.nginx.plist","exit_code":null}] + """ + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("brew-root-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let daemonPath = tmp.appendingPathComponent("homebrew.mxcl.nginx.plist") + try Data().write(to: daemonPath) + + let brewService = BrewServicesService( + shell: MockShell( + brewJSON: json, + launchctlOutputs: ["system/homebrew.mxcl.nginx": "state = running\n"] + ), + brewPath: "/opt/homebrew/bin/brew", + systemDaemonDirectory: tmp.path + ) + + let items = try brewService.listServices(scope: .root) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(items[0].scope, .root) + XCTAssertEqual(items[0].status, .started) + XCTAssertEqual(items[0].plistPath, daemonPath.path) + } + + func test_homebrewServiceStatus_mapping() { + XCTAssertEqual(HomebrewServiceStatus(brewStatus: "started"), .started) + XCTAssertEqual(HomebrewServiceStatus(brewStatus: "stopped"), .stopped) + XCTAssertEqual(HomebrewServiceStatus(brewStatus: "none"), .none) + XCTAssertEqual(HomebrewServiceStatus(brewStatus: "error"), .error) + } +}