├── .gitignore ├── README.md ├── Package.swift └── Sources └── beats-by-me └── beats_by_me.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | You can build this with Swift Package Manager. 2 | 3 | ```bash 4 | swift build -c release 5 | ``` 6 | 7 | You'll end up with the binary in `.build/arm64-apple-macosx/release`. 8 | 9 | # Issues 10 | 11 | The preferences window opens behind other ones. This is apparently a known problem with `MenuBarExtra` and `SettingsLink` and I cannot be bothered to figure it out. 12 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "beats-by-me", 8 | platforms: [.macOS(.v14)], 9 | targets: [ 10 | // Targets are the basic building blocks of a package, defining a module or a test suite. 11 | // Targets can depend on other targets in this package and products from dependencies. 12 | .executableTarget( 13 | name: "beats-by-me" 14 | ), 15 | ] 16 | ) 17 | -------------------------------------------------------------------------------- /Sources/beats-by-me/beats_by_me.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | import ServiceManagement 4 | internal import Combine 5 | 6 | @main 7 | struct SwatchBeatsApp: App { 8 | @StateObject private var model = BeatsModel() 9 | 10 | var body: some Scene { 11 | // Menu bar item 12 | MenuBarExtra(content: { 13 | // MENU CONTENT 14 | Button("Copy \(model.copyIncludesAt ? "" : "beats without @")") { 15 | model.copyToPasteboard() 16 | } 17 | Divider() 18 | SettingsLink { Text("Preferences…") } 19 | Button("Quit") { NSApp.terminate(nil) } 20 | .keyboardShortcut("q", modifiers: [.command]) 21 | }, label: { 22 | // MENUBAR TITLE 23 | Text(model.title) 24 | .font(.system(.body, design: .monospaced)) 25 | .help("Swatch Internet Time (@beats)") 26 | }) 27 | .menuBarExtraStyle(.menu) // standard menu 28 | 29 | // Preferences window 30 | Settings { 31 | PreferencesView() 32 | } 33 | } 34 | } 35 | 36 | // MARK: - Model 37 | 38 | final class BeatsModel: ObservableObject { 39 | // var objectWillChange: ObservableObjectPublisher 40 | 41 | @AppStorage("showTenths") var showTenths: Bool = false { didSet { updateTitle() } } 42 | @AppStorage("copyIncludesAt") var copyIncludesAt: Bool = true 43 | @AppStorage("launchAtLogin") var launchAtLogin: Bool = false 44 | 45 | @Published var title: String = "@000" 46 | 47 | private var timer: Timer? 48 | 49 | init() { 50 | // Sync stored toggle with actual system state (macOS 13+) 51 | if #available(macOS 13.0, *) { 52 | let enabled = (SMAppService.mainApp.status == .enabled) 53 | if enabled != launchAtLogin { launchAtLogin = enabled } 54 | } 55 | start() 56 | } 57 | 58 | deinit { timer?.invalidate() } 59 | 60 | func start() { 61 | timer?.invalidate() 62 | timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in 63 | self?.updateTitle() 64 | } 65 | RunLoop.main.add(timer!, forMode: .common) 66 | updateTitle() 67 | } 68 | 69 | func updateTitle() { 70 | let beatsValue = Self.swatchBeatsDouble(from: Date()) 71 | if showTenths { 72 | title = "@\(Self.formatTenths(beatsValue))" 73 | } else { 74 | title = "@\(String(format: "%03d", Int(floor(beatsValue)) % 1000))" 75 | } 76 | } 77 | 78 | func copyToPasteboard() { 79 | let text = copyIncludesAt ? title : String(title.dropFirst()) 80 | let pb = NSPasteboard.general 81 | pb.clearContents() 82 | pb.setString(text, forType: .string) 83 | } 84 | 85 | func openPreferences() { 86 | NSApp.activate(ignoringOtherApps: true) 87 | // SwiftUI way to open Settings (works on macOS 13+) 88 | NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) 89 | } 90 | 91 | // Swatch Internet Time math 92 | static func swatchBeatsDouble(from date: Date) -> Double { 93 | let utc = date.timeIntervalSince1970 94 | let bmt = utc + 3600.0 // UTC+1 fixed (no DST) 95 | let secondsInDay = (bmt.truncatingRemainder(dividingBy: 86400.0) + 86400.0) 96 | .truncatingRemainder(dividingBy: 86400.0) 97 | let beats = secondsInDay / 86.4 98 | return beats >= 0 ? beats.truncatingRemainder(dividingBy: 1000.0) 99 | : (beats.truncatingRemainder(dividingBy: 1000.0) + 1000.0) 100 | } 101 | 102 | static func formatTenths(_ beats: Double) -> String { 103 | let whole = Int(floor(beats)) % 1000 104 | let tenth = Int((beats - floor(beats)) * 10.0 + 0.0001) % 10 105 | return "\(String(format: "%03d", whole)).\(tenth)" 106 | } 107 | } 108 | 109 | // MARK: - Preferences 110 | 111 | struct PreferencesView: View { 112 | @AppStorage("showTenths") private var showTenths: Bool = false 113 | @AppStorage("copyIncludesAt") private var copyIncludesAt: Bool = true 114 | @AppStorage("launchAtLogin") private var launchAtLogin: Bool = false 115 | 116 | @State private var errorText: String? 117 | 118 | var body: some View { 119 | VStack(alignment: .leading, spacing: 16) { 120 | Form { 121 | Toggle("Show tenths (e.g., @123.4)", isOn: $showTenths) 122 | Toggle("Copy includes “@”", isOn: $copyIncludesAt) 123 | Toggle("Launch at login", isOn: $launchAtLogin) 124 | .onChange(of: launchAtLogin) { oldValue, newValue in 125 | setLaunchAtLogin(newValue) 126 | } 127 | .help("Start this app automatically when you log in (macOS 13+).") 128 | } 129 | .padding(.top, 8) 130 | 131 | if let errorText { 132 | Text("Couldn’t change Launch at login: \(errorText)") 133 | .font(.footnote) 134 | .foregroundColor(.red) 135 | } 136 | 137 | Divider() 138 | Text("Swatch Internet Time uses BMT (UTC+1 year-round). 1 beat = 86.4 seconds.") 139 | .font(.footnote) 140 | .foregroundColor(.secondary) 141 | } 142 | .padding(20) 143 | .frame(width: 420) 144 | .onAppear(perform: syncLaunchAtLoginState) 145 | } 146 | 147 | private func syncLaunchAtLoginState() { 148 | if #available(macOS 13.0, *) { 149 | let enabled = (SMAppService.mainApp.status == .enabled) 150 | if enabled != launchAtLogin { 151 | launchAtLogin = enabled 152 | } 153 | } 154 | } 155 | 156 | private func setLaunchAtLogin(_ enabled: Bool) { 157 | guard #available(macOS 13.0, *) else { 158 | errorText = "Requires macOS 13 or later." 159 | launchAtLogin = false 160 | return 161 | } 162 | do { 163 | if enabled { 164 | try SMAppService.mainApp.register() 165 | } else { 166 | try SMAppService.mainApp.unregister() 167 | } 168 | errorText = nil 169 | } catch { 170 | launchAtLogin.toggle() // revert 171 | errorText = error.localizedDescription 172 | } 173 | } 174 | } 175 | --------------------------------------------------------------------------------