~izzy/Tomatsupo

efc66e280567343a1041abed25448775d80b98d1 — Izzy Miller a month ago 999bd64 main
WIP, remove 10.15, debugging long timer issue
M Tomatsupo.xcodeproj/project.pbxproj => Tomatsupo.xcodeproj/project.pbxproj +2 -2
@@ 301,7 301,7 @@
					"$(inherited)",
					"@executable_path/../Frameworks",
				);
				MACOSX_DEPLOYMENT_TARGET = 10.15;
				MACOSX_DEPLOYMENT_TARGET = 11.0;
				MARKETING_VERSION = 0.0.1;
				PRODUCT_BUNDLE_IDENTIFIER = fm.stardust.app.Tomatsupo;
				PRODUCT_NAME = "$(TARGET_NAME)";


@@ 327,7 327,7 @@
					"$(inherited)",
					"@executable_path/../Frameworks",
				);
				MACOSX_DEPLOYMENT_TARGET = 10.15;
				MACOSX_DEPLOYMENT_TARGET = 11.0;
				MARKETING_VERSION = 0.0.1;
				PRODUCT_BUNDLE_IDENTIFIER = fm.stardust.app.Tomatsupo;
				PRODUCT_NAME = "$(TARGET_NAME)";

M Tomatsupo.xcodeproj/xcuserdata/izzym.xcuserdatad/xcschemes/xcschememanagement.plist => Tomatsupo.xcodeproj/xcuserdata/izzym.xcuserdatad/xcschemes/xcschememanagement.plist +8 -0
@@ 10,5 10,13 @@
			<integer>0</integer>
		</dict>
	</dict>
	<key>SuppressBuildableAutocreation</key>
	<dict>
		<key>83EBE73825A422B3002C9CBF</key>
		<dict>
			<key>primary</key>
			<true/>
		</dict>
	</dict>
</dict>
</plist>

M Tomatsupo/DoNotDisturb.swift => Tomatsupo/DoNotDisturb.swift +8 -105
@@ 15,21 15,7 @@ public enum DoNotDisturbState {
    case disabled
}

public protocol DoNotDisturb {
    var doNotDisturb: DoNotDisturbState {get set}
}

public class DoNotDisturbGenerator {
    class func make() -> DoNotDisturb {
        if #available(macOS 11, *) {
            return DoNotDisturbBigSur()
        } else {
            return DoNotDisturbCatalina()
        }
    }
}

public struct DoNotDisturbBigSur: DoNotDisturb {
public struct DoNotDisturb {

    private var preservePrevious = UserDefaults(suiteName: "com.apple.ncprefs.plist")!.data(forKey: "dnd_prefs")!.hexEncodedString(options: Data.HexEncodingOptions.upperCase)
    


@@ 59,98 45,15 @@ extension Data {

    func hexEncodedString(options: HexEncodingOptions = []) -> String {
        let hexDigits = options.contains(.upperCase) ? "0123456789ABCDEF" : "0123456789abcdef"
        if #available(macOS 11.0, iOS 14.0, watchOS 7.0, tvOS 14.0, *) {
            let utf8Digits = Array(hexDigits.utf8)
            return String(unsafeUninitializedCapacity: 2 * count) { (ptr) -> Int in
                var p = ptr.baseAddress!
                for byte in self {
                    p[0] = utf8Digits[Int(byte / 16)]
                    p[1] = utf8Digits[Int(byte % 16)]
                    p += 2
                }
                return 2 * count
            }
        } else {
            let utf16Digits = Array(hexDigits.utf16)
            var chars: [unichar] = []
            chars.reserveCapacity(2 * count)
        let utf8Digits = Array(hexDigits.utf8)
        return String(unsafeUninitializedCapacity: 2 * count) { (ptr) -> Int in
            var p = ptr.baseAddress!
            for byte in self {
                chars.append(utf16Digits[Int(byte / 16)])
                chars.append(utf16Digits[Int(byte % 16)])
            }
            return String(utf16CodeUnits: chars, count: chars.count)
        }
    }
}

// The following code is Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
// and used under the MIT license - https://github.com/sindresorhus/do-not-disturb
public struct DoNotDisturbCatalina: DoNotDisturb{
    private static let appId = "com.apple.notificationcenterui" as CFString

    private func set(_ key: String, value: CFPropertyList?) {
        CFPreferencesSetValue(key as CFString, value, DoNotDisturbCatalina.appId, kCFPreferencesCurrentUser, kCFPreferencesCurrentHost)
    }

    private func commitChanges() {
        CFPreferencesSynchronize(DoNotDisturbCatalina.appId, kCFPreferencesCurrentUser, kCFPreferencesCurrentHost)
        DistributedNotificationCenter.default().postNotificationName(NSNotification.Name("com.apple.notificationcenterui.dndprefs_changed"), object: nil, deliverImmediately: true)
    }

    private func restartNotificationCenter() {
        NSRunningApplication.runningApplications(withBundleIdentifier: "com.apple.notificationcenterui").first?.forceTerminate()
    }

    private func enable() {
        guard doNotDisturb == .disabled else {
            return
        }

        set("doNotDisturb", value: true as CFPropertyList)
        set("doNotDisturbDate", value: Date() as CFPropertyList)
        commitChanges()
        restartNotificationCenter()
    }

    private func disable() {
        guard doNotDisturb == .enabled else {
            return
        }

        set("doNotDisturb", value: false as CFPropertyList)
        set("doNotDisturbDate", value: nil)
        commitChanges()
        restartNotificationCenter()
        restoreMenubarIcon()
    }

    private func restoreMenubarIcon() {
        set("dndStart", value: 0 as CFPropertyList)
        set("dndEnd", value: 1440 as CFPropertyList)

        // We need to sleep for a little bit, otherwise it doesn't take effect.
        // It works with 0.3, but not with 0.2, so we're using 0.4 just to be sure.
        usleep(useconds_t(0.4 * Double(USEC_PER_SEC)))

        set("dndStart", value: nil)
        set("dndEnd", value: nil)
        commitChanges()
    }

    public var doNotDisturb: DoNotDisturbState {
        get {
            let state = CFPreferencesGetAppBooleanValue("doNotDisturb" as CFString, DoNotDisturbCatalina.appId, nil)
            return state ? .enabled : .disabled
        }
        set {
            switch newValue {
            case .enabled:
                enable()
            case .disabled:
                disable()
            case .original:
                disable() //TODO
                p[0] = utf8Digits[Int(byte / 16)]
                p[1] = utf8Digits[Int(byte % 16)]
                p += 2
            }
            return 2 * count
        }
    }
}

M Tomatsupo/Logger.swift => Tomatsupo/Logger.swift +2 -1
@@ 8,9 8,10 @@
import Foundation
import os.log

@available(OSX 11.0, *)
public extension Logger {
    private static var subsystem = Bundle.main.bundleIdentifier!
    static let notifications = Logger(subsystem: subsystem, category: "notifications")
    static let model = Logger(subsystem: subsystem, category: "model")
    static let async = Logger(subsystem: subsystem, category: "async")
    static let ui = Logger(subsystem: subsystem, category: "ui")
}

M Tomatsupo/Model.swift => Tomatsupo/Model.swift +0 -33
@@ 26,39 26,6 @@ struct Model {
    
    var deadline: DispatchTime?
    
    func remainingSeconds() -> Int? {
        guard let deadline = deadline else {
            return nil
        }
        
        switch DispatchTime.now().distance(to: deadline) {
        case .seconds(let dist):
            return dist
        default:
            if #available(OSX 11.0, *) {
                Logger.async.info("Unhandled case in remainingSeconds")
            } else {
                // TODO: Logging on Catalina
            }
            return nil
        }
    }
    
    func fuzzyFormat(seconds: Int) -> String {
        switch seconds {
        case 0..<300:
            return "Less than five minutes to go!"
        case 300..<500:
            return "Less than ten minutes..."
        case 500..<780:
            return "About ten minutes left"
        case 780..<(17*60):
            return "About fifteen minutes, eh?"
        default:
            return "Time to get some work done!"
        }
    }
    
    init(debug: Bool = false) {
        let userDefaults = UserDefaults.standard
        userDefaults.register(

M Tomatsupo/TomatsupoApp.swift => Tomatsupo/TomatsupoApp.swift +65 -16
@@ 29,11 29,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
    var model = Model()
    
    var workItem, dndWhileWorkingItem: NSMenuItem?
    var dnd: DoNotDisturb = DoNotDisturbGenerator.make()
    var infoItem = NSMenuItem()
    
    var notificationUUID: String?
    
    var dnd: DoNotDisturb = DoNotDisturb()
    var workTask: DispatchWorkItem?
    
    #if DEBUG
    var debugItem = NSMenuItem(title: "Use short time", action: #selector(handleDebugToggle(_:)), keyEquivalent: "")
    #endif

    func applicationDidFinishLaunching(_ notification: Notification) {

        #if DEBUG
        print("Running in Debug configuration")
        #endif
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
        
        if let button = statusItem?.button {


@@ 48,6 58,10 @@ class AppDelegate: NSObject, NSApplicationDelegate {
        }
    }
    
    func applicationWillTerminate(_ notification: Notification) {
        reset()
    }
    
    func createMenu() {
        menu.autoenablesItems = false
        workItem = NSMenuItem(title: "Start working",


@@ 55,16 69,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
                              keyEquivalent: ".")
        menu.addItem(workItem!)
        
        infoItem.title = ""
        infoItem.isHidden = true
        infoItem.isEnabled = false
        menu.addItem(infoItem)
        
        dndWhileWorkingItem =
            NSMenuItem(title: "Enable Do Not Disturb while working",
                       action: #selector(handleToggleDnDWhileWorking(_:)),
                       keyEquivalent: "")
        
        dndWhileWorkingItem?.state = model.enableDnDWhileWorking ? .on : .off
        
        menu.addItem(dndWhileWorkingItem!)
        
        menu.addItem(NSMenuItem.separator())
        #if DEBUG
        debugItem.state = .on
        menu.addItem(debugItem)
        model.workDuration = 1
        #endif
        menu.addItem(NSMenuItem(title: "Quit",
                                action: #selector(NSApplication.terminate(_:)),
                                keyEquivalent: "q"))


@@ 75,12 98,19 @@ class AppDelegate: NSObject, NSApplicationDelegate {
        case nil:
            workTask = DispatchWorkItem {
                self.reset()
                self.notifyUser()

            }
            
            if model.enableDnDWhileWorking {
                dnd.doNotDisturb = .enabled
            }
            self.scheduleNotification()
            let startTime = Date()
            let formatter = DateFormatter()
            formatter.dateStyle = .none
            formatter.timeStyle = .short
            infoItem.title = "Began working at \(formatter.string(from: startTime))"
            infoItem.isHidden = false
            
            model.deadline = .now() + .seconds(model.workDuration*60)
            DispatchQueue.main.asyncAfter(deadline: model.deadline!, execute: workTask!)


@@ 104,37 134,50 @@ class AppDelegate: NSObject, NSApplicationDelegate {
        }
    }
    
    #if DEBUG
    @objc func handleDebugToggle(_ sender: NSMenuItem) {
        if model.workDuration != 1 {
            logger.model.info("Setting short time")
            debugItem.state = .on
            model.workDuration = 1
        } else {
            print("Setting regular time")
            debugItem.state = .off
            model.workDuration = 25
        }
    }
    #endif
    
    func requestNotifications() {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
            if let error = error {
                if #available(OSX 11.0, *) {
                    Logger.notifications.warning("Well that sucks - you didn't give us permission to notify you (\(error.localizedDescription)")
                } else {
                    // Todo - earlier logging
                }
                Logger.notifications.warning("Well that sucks - you didn't give us permission to notify you (\(error.localizedDescription)")
                return
            }
        }
    }

    func notifyUser() {
    func scheduleNotification() {
        Logger.notifications.debug("Scheduling notification (in \(self.model.workDuration)m)")

        let content = UNMutableNotificationContent()
        
        let uuid = UUID().uuidString
        notificationUUID = uuid
        
        content.title = "Work timer completed!"
        content.body = "As Blake would say, go grab a quarentini 🍸"
        content.categoryIdentifier = "alert"
        content.sound = UNNotificationSound.default
        
        let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
        let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(model.workDuration * 60), repeats: false)
        let request = UNNotificationRequest(identifier: uuid, content: content, trigger: trigger)
        let notificationCenter = UNUserNotificationCenter.current()
        
        notificationCenter.add(request, withCompletionHandler: { error in
            if let error = error  {
                if #available(OSX 11.0, *) {
                    Logger.notifications.error("Error notifying: \(error.localizedDescription)")
                } else {
                    // TODO: Logging on older OS versions
                }
                Logger.notifications.error("Error notifying: \(error.localizedDescription)")
            }
        })
    }


@@ 143,8 186,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
        if dndWhileWorkingItem?.state == .on {
            dnd.doNotDisturb = .original
        }
        if let uuid = notificationUUID {
            let nc = UNUserNotificationCenter.current()
            nc.removePendingNotificationRequests(withIdentifiers: [uuid])
        }
        
        dndWhileWorkingItem?.isEnabled = true
        workItem?.title = "Start working"
        workTask = nil
        infoItem.isHidden = true
    }
}