Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
SWIFTLINT := $(shell command -v swiftlint 2>/dev/null || echo mise exec -- swiftlint)

.PHONY: default clean build open test uitest lint analyze format
.PHONY: default clean build open test uitest lint analyze format install-cli

default: clean build open

Expand Down Expand Up @@ -28,3 +28,6 @@ analyze:

format:
$(SWIFTLINT) --fix

install-cli:
install -m 755 timer-cli /usr/local/bin/timer
8 changes: 8 additions & 0 deletions Timer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
AA000002AAAA000100000002 /* TimerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000002AAAA000000000002 /* TimerTests.swift */; };
BB000001BBBB000100000001 /* TimerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB000001BBBB000000000001 /* TimerUITests.swift */; };
CC000001CCCC000100000001 /* MVClockView+Behavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000001CCCC000100000000 /* MVClockView+Behavior.swift */; };
DD000001DDDD000100000001 /* TimerScriptCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD000001DDDD000100000000 /* TimerScriptCommand.swift */; };
DD000002DDDD000200000001 /* Timer.sdef in Resources */ = {isa = PBXBuildFile; fileRef = DD000002DDDD000200000000 /* Timer.sdef */; };
CC000010CCCC001000000001 /* TimerUITestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000010CCCC001000000000 /* TimerUITestCase.swift */; };
CC000011CCCC001100000001 /* TimerKeyboardInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000011CCCC001100000000 /* TimerKeyboardInputTests.swift */; };
CC000012CCCC001200000001 /* TimerInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC000012CCCC001200000000 /* TimerInteractionTests.swift */; };
Expand Down Expand Up @@ -85,6 +87,8 @@
CC000013CCCC001300000000 /* TimerDockAndCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerDockAndCompletionTests.swift; sourceTree = "<group>"; };
CC000014CCCC001400000000 /* TimerStateTransitionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerStateTransitionTests.swift; sourceTree = "<group>"; };
CC000015CCCC001500000000 /* TimerInputEdgeCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerInputEdgeCaseTests.swift; sourceTree = "<group>"; };
DD000001DDDD000100000000 /* TimerScriptCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerScriptCommand.swift; sourceTree = "<group>"; };
DD000002DDDD000200000000 /* Timer.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = Timer.sdef; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -148,6 +152,8 @@
4C6F0F201CAA3D8600E9A6F7 /* MVLabel.swift */,
723C0C571E12211000E7AE1E /* MVUserDefaultsKeys.swift */,
AA000001AAAA000000000001 /* TimerLogic.swift */,
DD000001DDDD000100000000 /* TimerScriptCommand.swift */,
DD000002DDDD000200000000 /* Timer.sdef */,
4C30BBFD1CA7C56500C45EBF /* Assets.xcassets */,
4C30BC021CA7C56500C45EBF /* Info.plist */,
41A37D0624426E360078EA40 /* Credits.rtf */,
Expand Down Expand Up @@ -292,6 +298,7 @@
40A85EF925A64C3100DA10CC /* alert-sound-3.caf in Resources */,
40A85EFA25A64C3100DA10CC /* alert-sound-2.caf in Resources */,
41A37D0724426E360078EA40 /* Credits.rtf in Resources */,
DD000002DDDD000200000001 /* Timer.sdef in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -329,6 +336,7 @@
AA000011AAAA001100000001 /* MVClockArrowView.swift in Sources */,
AA000012AAAA001200000001 /* MVClockFaceView.swift in Sources */,
CC000001CCCC000100000001 /* MVClockView+Behavior.swift in Sources */,
DD000001DDDD000100000001 /* TimerScriptCommand.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
108 changes: 108 additions & 0 deletions Timer/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,114 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

self.observeNotifications()
self.staysOnTop = UserDefaults.standard.bool(forKey: MVUserDefaultsKeys.staysOnTop)

let parsed = Self.parseLaunchArguments(CommandLine.arguments)
if let command = parsed.command {
self.handleTimerCommand(command, window: parsed.window)
}
}

func application(_: NSApplication, open urls: [URL]) {
guard let url = urls.first, url.scheme == "timer" else { return }
// Parse raw string to avoid URL treating ":" as port separator
// e.g. timer://2:30?window=2 → command "2:30", window 2
var raw = url.absoluteString
.replacingOccurrences(of: "timer://", with: "")
.removingPercentEncoding ?? ""
var window: Int?
if let queryStart = raw.firstIndex(of: "?") {
let query = String(raw[raw.index(after: queryStart)...])
raw = String(raw[..<queryStart])
for param in query.split(separator: "&") {
let pair = param.split(separator: "=", maxSplits: 1)
if pair.count == 2, pair[0] == "window", let value = Int(pair[1]) {
window = value
}
}
}
while raw.hasSuffix("/") { raw.removeLast() }
self.handleTimerCommand(raw, window: window)
}

func handleTimerCommand(_ input: String, window: Int? = nil) {
if input.lowercased() == "new" {
self.newDocument(nil)
return
}

let index = (window ?? 1) - 1
guard self.controllers.indices.contains(index) else { return }
let controller = self.controllers[index]
let clockView = controller.clockView

switch input.lowercased() {
case "stop":
clockView.paused = false
clockView.stop()

case "reset":
clockView.paused = false
clockView.stop()
clockView.seconds = 0
clockView.updateTimerTime()
clockView.inputSeconds = false

case "pause":
if clockView.timerTask != nil {
clockView.paused = true
clockView.stop()
} else if clockView.paused, clockView.seconds > 0 {
clockView.updateTimerTime()
clockView.start()
}

default:
guard let seconds = self.parseTimeInput(input), seconds > 0 else { return }
clockView.startTimer(seconds: seconds)
}

controller.window?.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)
}

static func parseLaunchArguments(_ args: [String]) -> (command: String?, window: Int?) {
// args[0] is the executable path; skip it
var command: String?
var window: Int?
var skip = false
for idx in 1..<args.count {
if skip { skip = false; continue }
if args[idx] == "--window" {
if idx + 1 < args.count, let value = Int(args[idx + 1]) {
window = value
skip = true
}
} else {
command = args[idx]
}
}
return (command, window)
}

private static let maxTimerSeconds: CGFloat = 24 * 60 * 60 // 24 hours

private func parseTimeInput(_ input: String) -> CGFloat? {
let seconds: CGFloat

// "2:50" → 2 minutes 50 seconds (colon = literal minutes:seconds)
if input.contains(":") {
let parts = input.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
guard parts.count == 2, let minutes = Double(parts[0]), let secs = Double(parts[1]) else { return nil }
seconds = CGFloat(minutes * 60 + secs)
} else if let value = Double(input) {
// "2.5" → 2.5 minutes = 2m30s (dot = fractional minutes)
seconds = CGFloat(value * 60)
} else {
return nil
}

guard seconds.isFinite, seconds <= Self.maxTimerSeconds else { return nil }
return seconds
}

func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
Expand Down
15 changes: 15 additions & 0 deletions Timer/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@
<string>public.app-category.utilities</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.michaelvillar.Timer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timer</string>
</array>
</dict>
</array>
<key>NSAppleScriptEnabled</key>
<true/>
<key>OSAScriptingDefinition</key>
<string>Timer.sdef</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
Expand Down
8 changes: 8 additions & 0 deletions Timer/MVClockView+Behavior.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,14 @@ extension MVClockView {
}
}

func startTimer(seconds: CGFloat) {
self.paused = false
self.stop()
self.seconds = seconds
self.updateTimerTime()
self.start()
}

private func tick() {
guard let timerTime = self.timerTime else { return }

Expand Down
2 changes: 1 addition & 1 deletion Timer/MVTimerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import UserNotifications

final class MVTimerController: NSWindowController {
private weak var dockMenuItem: NSMenuItem?
private let clockView = MVClockView()
let clockView = MVClockView()

private var audioPlayer: AVAudioPlayer? // player must be kept in memory
private var soundURL = Bundle.main.url(forResource: "alert-sound", withExtension: "caf")
Expand Down
40 changes: 40 additions & 0 deletions Timer/Timer.sdef
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
<dictionary title="Timer Terminology">
<suite name="Timer Suite" code="TMRs" description="Commands to control the timer.">

<command name="start timer" code="TMRsSTRT" description="Start a timer with the given duration.">
<direct-parameter type="text" description="Duration (e.g. '5' for 5 min, '2.5' for 2m30s, '2:30' for 2m30s)."/>
<parameter name="window" code="TMRw" type="integer" optional="yes" description="Window number (1-based, default 1).">
<cocoa key="window"/>
</parameter>
<cocoa class="TimerScriptCommand"/>
</command>

<command name="stop timer" code="TMRsSTPP" description="Stop the timer.">
<parameter name="window" code="TMRw" type="integer" optional="yes" description="Window number (1-based, default 1).">
<cocoa key="window"/>
</parameter>
<cocoa class="TimerScriptCommand"/>
</command>

<command name="pause timer" code="TMRsPAUS" description="Pause or unpause the timer.">
<parameter name="window" code="TMRw" type="integer" optional="yes" description="Window number (1-based, default 1).">
<cocoa key="window"/>
</parameter>
<cocoa class="TimerScriptCommand"/>
</command>

<command name="reset timer" code="TMRsRSET" description="Stop the timer and reset to zero.">
<parameter name="window" code="TMRw" type="integer" optional="yes" description="Window number (1-based, default 1).">
<cocoa key="window"/>
</parameter>
<cocoa class="TimerScriptCommand"/>
</command>

<command name="new timer" code="TMRsNEWT" description="Open a new timer window.">
<cocoa class="TimerScriptCommand"/>
</command>

</suite>
</dictionary>
35 changes: 35 additions & 0 deletions Timer/TimerScriptCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import AppKit

final class TimerScriptCommand: NSScriptCommand {
override func performDefaultImplementation() -> Any? {
let command: String
switch self.commandDescription.commandName {
case "start timer":
command = self.directParameter as? String ?? ""

case "stop timer":
command = "stop"

case "reset timer":
command = "reset"

case "pause timer":
command = "pause"

case "new timer":
command = "new"

default:
return nil
}

let window = self.evaluatedArguments?["window"] as? Int

Task { @MainActor in
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
appDelegate.handleTimerCommand(command, window: window)
}

return nil
}
}
22 changes: 22 additions & 0 deletions timer-cli
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/sh
window=""
command=""

while [ $# -gt 0 ]; do
case "$1" in
-w|--window)
window="$2"
shift 2
;;
*)
command="$1"
shift
;;
esac
done

if [ -n "$window" ]; then
open "timer://${command}?window=${window}"
else
open "timer://${command}"
fi