├── .gitignore ├── LICENSE ├── Package.swift ├── PluginBinary └── PluginBinary.swift ├── Plugins └── DoNilDisturb │ └── DoNotDisturbPlugin.swift ├── README.md ├── Sources └── MyApp │ └── MyApp.swift ├── Tests └── DoNilDisturbPluginTests │ ├── DoNotDisturbPluginTests.swift │ └── Resources │ └── TestCal.ics └── etc ├── dnd.png └── holidays.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marin Todorov 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 all 13 | 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 FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "DoNilDisturbPlugin", 6 | platforms: [ 7 | .macOS(.v11), 8 | .iOS(.v13), 9 | .tvOS(.v13), 10 | .watchOS(.v8) 11 | ], 12 | products: [ 13 | .plugin(name: "DoNilDisturbPlugin", targets: ["DoNilDisturb"]) 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/icanzilb/iCalendar.git", from: "0.0.1") 17 | ], 18 | targets: [ 19 | // Your app or library 20 | .executableTarget( 21 | name: "MyApp", 22 | plugins: [ 23 | .plugin(name: "DoNilDisturb") 24 | ] 25 | ), 26 | // Your plugin's IMPLEMENTATION 27 | .executableTarget( 28 | name: "PluginBinary", 29 | dependencies: [ 30 | .product(name: "iCalendar", package: "iCalendar") 31 | ], 32 | path: "PluginBinary" 33 | ), 34 | // Your plugin's INTERFACE 35 | .plugin( 36 | name: "DoNilDisturb", 37 | capability: .buildTool(), 38 | dependencies: [ 39 | .target(name: "PluginBinary") 40 | ] 41 | ), 42 | .testTarget( 43 | name: "DoNilDisturbPluginTests", 44 | dependencies: [ 45 | .target(name: "PluginBinary"), 46 | .product(name: "iCalendar", package: "iCalendar") 47 | ], 48 | resources: [ 49 | .copy("Resources"), 50 | ] 51 | ) 52 | ] 53 | ) 54 | -------------------------------------------------------------------------------- /PluginBinary/PluginBinary.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import iCalendar 3 | 4 | extension String: Error { } 5 | 6 | @main class PluginBinary { 7 | private static var logs = ["#warning(\"logs ⏬\")", "/*", ""] 8 | static func log(_ string: String?) { 9 | logs.append(string ?? "nil") 10 | } 11 | 12 | static func writeLog(at url: URL) throws { 13 | logs.append("") 14 | logs.append("*/") 15 | try logs 16 | .joined(separator: "\n") 17 | .write(to: url, atomically: true, encoding: .utf8) 18 | } 19 | 20 | static func main() throws { 21 | let invocation = try JSONDecoder().decode(PluginInvocation.self, from: Data(ProcessInfo.processInfo.arguments[1].utf8)) 22 | 23 | // Get holiday calendars 24 | for calPath in invocation.calendarPaths { 25 | log("Holidays: \(calPath.replacingOccurrences(of: invocation.packagePath + "/", with: ""))") 26 | } 27 | 28 | let holidayCalendars = try invocation.calendarPaths 29 | .compactMap({ 30 | return Parser.parse(ics: try String(contentsOfFile: $0)).value 31 | }) 32 | 33 | // Write the output 34 | let outputURL = URL(fileURLWithPath: invocation.sourcePath) 35 | 36 | // let isoDate = "2022-01-01T10:44:00+0000" 37 | // let dateFormatter = ISO8601DateFormatter() 38 | // let date = dateFormatter.date(from:isoDate)! 39 | 40 | let date = Date() 41 | let content = content(for: date, holidayCalendars: holidayCalendars) 42 | try content.write(to: outputURL, atomically: true, encoding: .utf8) 43 | 44 | log("Written '\(invocation.sourcePath)'") 45 | 46 | // Write logs 47 | try writeLog(at: URL(fileURLWithPath: invocation.logPath)) 48 | } 49 | 50 | static func content(for date: Date, holidayCalendars: [iCalendar.Calendar], systemCalendar: Foundation.Calendar = .current) -> String { 51 | log("Current date: \(date.debugDescription)") 52 | 53 | if let holidaySummary = holidayCalendars 54 | .mapFirst(where: { calendar in 55 | return calendar.events.mapFirst { event in 56 | // iCalendar creates dates for all-day things at noon rather than midnight, so we need to compare date components instead of dates 57 | let startDateComponents = systemCalendar.dateComponents([.year, .month, .day], from: event.startDate) 58 | let currentDateComponents = systemCalendar.dateComponents([.year, .month, .day], from: date) 59 | log("Start components: \(startDateComponents)") 60 | log("Current components: \(currentDateComponents)") 61 | 62 | if startDateComponents.year == currentDateComponents.year, 63 | startDateComponents.month == currentDateComponents.month, 64 | startDateComponents.day == currentDateComponents.day { 65 | log("ITS NOW!") 66 | return "It is \(event.summary ?? "a holiday")" 67 | } 68 | return nil 69 | } 70 | }) { 71 | // Observe holidays 72 | return content(withReason: holidaySummary) 73 | } 74 | 75 | if systemCalendar.isDateInWeekend(date) { // Exclude weekend 76 | return content(withReason: "It's the weekend") 77 | } else if (9.0...18.0 ~= date.time) { 78 | // Time is in the 9am-6pm range 79 | return "// All is good, do not disturb is off." 80 | } 81 | 82 | let formatter = DateFormatter() 83 | formatter.dateStyle = .none 84 | formatter.timeStyle = .short 85 | formatter.locale = systemCalendar.locale 86 | 87 | return content(withReason: "It's \(formatter.string(from: date))") 88 | } 89 | 90 | private static func content(withReason reason: String) -> String { 91 | "#error(\"Do not disturb is ON\")\n" 92 | + "#warning(\"\(reason)\")" 93 | } 94 | } 95 | 96 | // https://stackoverflow.com/a/62616907/208205 97 | // https://creativecommons.org/licenses/by-sa/4.0/ 98 | extension Date { 99 | /// time returns a double for which the integer represents the hours from 1 to 24 and the decimal value represents the minutes. 100 | var time: Double { 101 | Double(Calendar.current.component(.hour, from: self)) + Double(Calendar.current.component(.minute, from: self)) / 100 102 | } 103 | } 104 | 105 | struct PluginInvocation: Codable { 106 | let packagePath: String 107 | let logPath: String 108 | let sourcePath: String 109 | let calendarPaths: [String] 110 | 111 | func encodedString() throws -> String { 112 | let data = try JSONEncoder().encode(self) 113 | return String(decoding: data, as: UTF8.self) 114 | } 115 | } 116 | 117 | extension Sequence { 118 | func mapFirst(where predicate: (Element) throws -> T?) rethrows -> T? { 119 | for element in self { 120 | if let result = try predicate(element) { 121 | return result 122 | } 123 | } 124 | return nil 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Plugins/DoNilDisturb/DoNotDisturbPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import PackagePlugin 3 | 4 | @main 5 | struct DoNilDisturb: BuildToolPlugin { 6 | 7 | func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] { 8 | let output = context.pluginWorkDirectory.appending(["DND.swift"]) 9 | let log = context.pluginWorkDirectory.appending(["log.swift"]) 10 | let files = try holidaysFileURLs(context: context) 11 | 12 | let invocation = PluginInvocation( 13 | packagePath: context.package.directory.string, 14 | logPath: log.string, 15 | sourcePath: output.string, 16 | calendarPaths: files.map(\.string) 17 | ) 18 | 19 | return [ 20 | .buildCommand( 21 | displayName: "Do Not Disturb", 22 | executable: try context.tool(named: "PluginBinary").path, 23 | arguments: [try invocation.encodedString()], 24 | outputFiles: [output, log] 25 | ) 26 | ] 27 | } 28 | 29 | func holidaysFileURLs(context: PackagePlugin.PluginContext) throws -> [Path] { 30 | let directory = context.package.directory.appending([".config"]) 31 | print("Config directory: \(directory)") 32 | guard let enumerator = FileManager.default.enumerator(atPath: directory.string) else { 33 | return [] 34 | } 35 | 36 | var result = [Path]() 37 | while let filePath = enumerator.nextObject() as? String { 38 | if filePath.hasSuffix(".ics") { 39 | result.append(directory.appending([filePath])) 40 | } 41 | } 42 | return result 43 | } 44 | } 45 | 46 | struct PluginInvocation: Codable { 47 | let packagePath: String 48 | let logPath: String 49 | let sourcePath: String 50 | let calendarPaths: [String] 51 | 52 | func encodedString() throws -> String { 53 | let data = try JSONEncoder().encode(self) 54 | return String(decoding: data, as: UTF8.self) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DoNilDisturb Swift Plugin 2 | 3 | Use Xcode 14+ to make use of this amazing and novel Swift plugin in your package. 4 | 5 | The plugin stops you from working on your 9-5 project outside of 9-5 hours: 6 | 7 | ![Project failing to compile with a message that do not disturb is on](etc/dnd.png) 8 | 9 | Add this to your dependencies in your Package.swift: 10 | 11 | ```swift 12 | .package(url: "https://github.com/icanzilb/DoNilDisturbPlugin.git", from: "0.0.2"), 13 | ``` 14 | 15 | **And then**, add the plugin in your target definition(still in Package.swift: 16 | 17 | ```swift 18 | .target( 19 | name: "MyTarget", 20 | plugins: [ 21 | .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") 22 | ] 23 | ) 24 | ``` 25 | 26 | That's all. Your target will fail to build outside of working hours. 27 | 28 | Enjoy your time off work. 29 | 30 | ## Public Holidays support 31 | 32 | Grab an **.ics** file containing the public holidays for your locality. For example, grab this one for Spanish holidays: https://www.officeholidays.com/ics-clean/spain 33 | 34 | Save the calendar file under your project's root directory in a sub-directory called `.config/DoNilDisturb` 35 | 36 | The plugin will now respect your holidays: 37 | 38 | ![Error message in Xcode failing to build because it's a public holiday](etc/holidays.png) 39 | 40 | ## License 41 | 42 | MIT, of course. -------------------------------------------------------------------------------- /Sources/MyApp/MyApp.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @main 4 | class MyApp { 5 | static func main() throws { 6 | print("My App runs! ") 7 | print("But it only builds during workng hours ") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/DoNilDisturbPluginTests/DoNotDisturbPluginTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Foundation 3 | import iCalendar 4 | 5 | @testable import PluginBinary 6 | 7 | final class DoNotDisturbPluginTests: XCTestCase { 8 | 9 | func testHolidayLoad() throws { 10 | let date = Foundation.Calendar.current.date(from: DateComponents(year: 2022, month: 8, day: 30, hour: 13))! 11 | let calendarFileURL = try XCTUnwrap(Bundle.module.url(forResource: "TestCal", withExtension: "ics", subdirectory: "Resources")) 12 | let icalContents = try String(contentsOf: calendarFileURL) 13 | let parseResult = Parser.parse(ics: icalContents) 14 | switch parseResult { 15 | case .success(let calendar): 16 | let content = PluginBinary.content(for: date, holidayCalendars: [calendar]) 17 | XCTAssertEqual(content, 18 | """ 19 | #error("Do not disturb is ON") 20 | #warning("It is The Feast of Maximum Occupancy") 21 | """) 22 | case .failure(let error): 23 | XCTFail(error.localizedDescription) 24 | } 25 | } 26 | 27 | func testWeekendError() throws { 28 | let sunday = Calendar.current.date(from: DateComponents(year: 2022, month: 08, day: 28))! 29 | 30 | let content = PluginBinary.content(for: sunday, holidayCalendars: []) 31 | XCTAssertEqual(content, 32 | """ 33 | #error("Do not disturb is ON") 34 | #warning("It's the weekend") 35 | """) 36 | } 37 | 38 | func test2amError() { 39 | var calendar = Foundation.Calendar.current 40 | 41 | // Prevents errors when the calendar is in a different locale 42 | calendar.locale = Locale(identifier: "en-US") 43 | 44 | let monday2am = calendar.date(from: DateComponents(year: 2022, month: 08, day: 29, hour: 2))! 45 | 46 | let content = PluginBinary.content(for: monday2am, 47 | holidayCalendars: [], 48 | systemCalendar: calendar) 49 | XCTAssertEqual(content, 50 | """ 51 | #error("Do not disturb is ON") 52 | #warning("It's 2:00 AM") 53 | """) 54 | } 55 | 56 | func testWorkTime() { 57 | let monday2pm = Calendar.current.date(from: DateComponents(year: 2022, month: 08, day: 29, hour: 14))! 58 | let content = PluginBinary.content(for: monday2pm, holidayCalendars: []) 59 | XCTAssertEqual(content, "// All is good, do not disturb is off.") 60 | } 61 | 62 | 63 | } 64 | -------------------------------------------------------------------------------- /Tests/DoNilDisturbPluginTests/Resources/TestCal.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | METHOD:PUBLISH 3 | VERSION:2.0 4 | X-WR-CALNAME:TestCal 5 | PRODID:-//Apple Inc.//macOS 12.5.1//EN 6 | X-APPLE-CALENDAR-COLOR:#0E61B9 7 | X-WR-TIMEZONE:America/Denver 8 | CALSCALE:GREGORIAN 9 | BEGIN:VEVENT 10 | CREATED:20220829T020826Z 11 | UID:413F797E-DA05-4920-AF63-D6A2E2D5CDC8 12 | DTSTART;VALUE=DATE:20220830 13 | DTEND;VALUE=DATE:20220831 14 | TRANSP:TRANSPARENT 15 | X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC 16 | SUMMARY:The Feast of Maximum Occupancy 17 | LAST-MODIFIED:20220829T020840Z 18 | DTSTAMP:20220829T020840Z 19 | SEQUENCE:1 20 | END:VEVENT 21 | END:VCALENDAR 22 | -------------------------------------------------------------------------------- /etc/dnd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icanzilb/DoNilDisturbPlugin/f7f80bd94af3cd327b27ccc4abf96b79d0ed069f/etc/dnd.png -------------------------------------------------------------------------------- /etc/holidays.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icanzilb/DoNilDisturbPlugin/f7f80bd94af3cd327b27ccc4abf96b79d0ed069f/etc/holidays.png --------------------------------------------------------------------------------