diff --git a/MacTorn/MacTorn.xcodeproj/project.pbxproj b/MacTorn/MacTorn.xcodeproj/project.pbxproj index f3e3cd8..1ce9245 100644 --- a/MacTorn/MacTorn.xcodeproj/project.pbxproj +++ b/MacTorn/MacTorn.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ AAA00007 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10007 /* StatusView.swift */; }; AAA00008 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10008 /* ProgressBarView.swift */; }; AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; }; + AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; }; + AAA00011 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10012 /* ShortcutsManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +31,8 @@ AAA10008 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; AAA10009 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; AAA10010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AAA10011 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = ""; }; + AAA10012 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = ""; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -112,6 +116,8 @@ isa = PBXGroup; children = ( AAA10009 /* NotificationManager.swift */, + AAA10011 /* LaunchAtLoginManager.swift */, + AAA10012 /* ShortcutsManager.swift */, ); path = Utilities; sourceTree = ""; @@ -193,6 +199,8 @@ AAA00007 /* StatusView.swift in Sources */, AAA00008 /* ProgressBarView.swift in Sources */, AAA00009 /* NotificationManager.swift in Sources */, + AAA00010 /* LaunchAtLoginManager.swift in Sources */, + AAA00011 /* ShortcutsManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MacTorn/MacTorn/MacTornApp.swift b/MacTorn/MacTorn/MacTornApp.swift index 911d724..8bbaf22 100644 --- a/MacTorn/MacTorn/MacTornApp.swift +++ b/MacTorn/MacTorn/MacTornApp.swift @@ -16,14 +16,29 @@ struct MacTornApp: App { } private var menuBarIcon: String { + // Error state if appState.errorMsg != nil { return "exclamationmark.triangle.fill" } + + // Traveling state + if let travel = appState.data?.travel, travel.isTraveling { + return "airplane" + } + + // Abroad state + if let travel = appState.data?.travel, travel.isAbroad { + return "globe" + } + + // Energy full state if let bars = appState.data?.bars { if bars.energy.current >= bars.energy.maximum { return "bolt.fill" } } + + // Default return "bolt" } } diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift index ba65f82..9e22282 100644 --- a/MacTorn/MacTorn/ViewModels/AppState.swift +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -13,9 +13,14 @@ class AppState: ObservableObject { @Published var errorMsg: String? @Published var isLoading: Bool = false + // MARK: - Managers + let launchAtLogin = LaunchAtLoginManager() + let shortcutsManager = ShortcutsManager() + // MARK: - State Comparison private var previousBars: Bars? private var previousCooldowns: Cooldowns? + private var previousTravel: Travel? // MARK: - Timer private var timerCancellable: AnyCancellable? @@ -88,6 +93,7 @@ class AppState: ObservableObject { // Store for comparison self.previousBars = decoded.bars self.previousCooldowns = decoded.cooldowns + self.previousTravel = decoded.travel } case 403, 404: self.errorMsg = "Invalid API Key" @@ -104,24 +110,25 @@ class AppState: ObservableObject { } private func checkNotifications(newData: TornResponse) { - guard let prev = previousBars, let current = newData.bars else { return } - - // Energy full notification - if prev.energy.current < prev.energy.maximum && - current.energy.current >= current.energy.maximum { - NotificationManager.shared.send( - title: "Energy Full! ⚡️", - body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))" - ) - } - - // Nerve full notification - if prev.nerve.current < prev.nerve.maximum && - current.nerve.current >= current.nerve.maximum { - NotificationManager.shared.send( - title: "Nerve Full! 💪", - body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))" - ) + // Bar notifications + if let prev = previousBars, let current = newData.bars { + // Energy full notification + if prev.energy.current < prev.energy.maximum && + current.energy.current >= current.energy.maximum { + NotificationManager.shared.send( + title: "Energy Full! ⚡️", + body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))" + ) + } + + // Nerve full notification + if prev.nerve.current < prev.nerve.maximum && + current.nerve.current >= current.nerve.maximum { + NotificationManager.shared.send( + title: "Nerve Full! 💪", + body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))" + ) + } } // Cooldown notifications @@ -145,6 +152,17 @@ class AppState: ObservableObject { ) } } + + // Travel notifications + if let prevTravel = previousTravel, let currentTravel = newData.travel { + // Landed notification + if prevTravel.isTraveling && !currentTravel.isTraveling { + NotificationManager.shared.send( + title: "Landed! ✈️", + body: "You have arrived in \(currentTravel.destination)" + ) + } + } } } diff --git a/MacTorn/MacTorn/Views/Components/ProgressBarView.swift b/MacTorn/MacTorn/Views/Components/ProgressBarView.swift index f175148..6b0d4c6 100644 --- a/MacTorn/MacTorn/Views/Components/ProgressBarView.swift +++ b/MacTorn/MacTorn/Views/Components/ProgressBarView.swift @@ -9,7 +9,11 @@ struct ProgressBarView: View { private var progress: Double { guard maximum > 0 else { return 0 } - return Double(current) / Double(maximum) + return min(1.0, Double(current) / Double(maximum)) + } + + private var isFull: Bool { + current >= maximum } var body: some View { @@ -17,31 +21,47 @@ struct ProgressBarView: View { HStack { Image(systemName: icon) .foregroundColor(color) - .font(.caption) + .font(.caption.bold()) Text(label) .font(.caption.bold()) + .foregroundColor(.primary) Spacer() Text("\(current)/\(maximum)") .font(.caption.monospacedDigit()) - .foregroundColor(.secondary) + .foregroundColor(isFull ? color : .secondary) + .fontWeight(isFull ? .bold : .regular) } + // Progress bar with visible styling GeometryReader { geometry in ZStack(alignment: .leading) { - // Background + // Background track RoundedRectangle(cornerRadius: 4) - .fill(color.opacity(0.2)) + .fill(Color.gray.opacity(0.3)) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(color.opacity(0.3), lineWidth: 1) + ) - // Foreground - RoundedRectangle(cornerRadius: 4) - .fill(color) - .frame(width: geometry.size.width * progress) + // Filled progress + if progress > 0 { + RoundedRectangle(cornerRadius: 4) + .fill( + LinearGradient( + colors: [color, color.opacity(0.7)], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(4, geometry.size.width * progress)) + .shadow(color: color.opacity(0.5), radius: 2, x: 0, y: 0) + } } } - .frame(height: 8) + .frame(height: 10) } } } @@ -49,9 +69,11 @@ struct ProgressBarView: View { #Preview { VStack(spacing: 16) { ProgressBarView(label: "Energy", current: 75, maximum: 100, color: .green, icon: "bolt.fill") - ProgressBarView(label: "Nerve", current: 25, maximum: 50, color: .red, icon: "flame.fill") - ProgressBarView(label: "Happy", current: 1000, maximum: 1000, color: .yellow, icon: "face.smiling.fill") + ProgressBarView(label: "Nerve", current: 50, maximum: 50, color: .red, icon: "flame.fill") + ProgressBarView(label: "Happy", current: 500, maximum: 1000, color: .yellow, icon: "face.smiling.fill") + ProgressBarView(label: "Life", current: 0, maximum: 100, color: .pink, icon: "heart.fill") } .padding() .frame(width: 280) + .background(Color(NSColor.windowBackgroundColor)) } diff --git a/MacTorn/MacTorn/Views/ContentView.swift b/MacTorn/MacTorn/Views/ContentView.swift index b5b1812..003a2cb 100644 --- a/MacTorn/MacTorn/Views/ContentView.swift +++ b/MacTorn/MacTorn/Views/ContentView.swift @@ -2,25 +2,42 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var appState: AppState + @State private var showSettings = false var body: some View { VStack(spacing: 0) { - if appState.apiKey.isEmpty { + if appState.apiKey.isEmpty || showSettings { SettingsView() + .environmentObject(appState) } else { + // Last updated + if let lastUpdated = appState.lastUpdated { + HStack { + Text("Updated: \(lastUpdated, formatter: timeFormatter)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal) + .padding(.top, 8) + } + StatusView() + .environmentObject(appState) } Divider() - .padding(.vertical, 8) + .padding(.vertical, 4) // Footer buttons HStack { - Button("Settings") { - appState.apiKey = "" // Go back to settings + if !appState.apiKey.isEmpty { + Button(showSettings ? "Back" : "Settings") { + showSettings.toggle() + } + .buttonStyle(.plain) + .foregroundColor(.secondary) } - .buttonStyle(.plain) - .foregroundColor(.secondary) Spacer() @@ -34,7 +51,12 @@ struct ContentView: View { .padding(.horizontal) .padding(.bottom, 8) } - .frame(width: 280) - .environmentObject(appState) + .frame(width: 300) + } + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.timeStyle = .short + return formatter } } diff --git a/MacTorn/MacTorn/Views/SettingsView.swift b/MacTorn/MacTorn/Views/SettingsView.swift index 55dd0f0..3f987f8 100644 --- a/MacTorn/MacTorn/Views/SettingsView.swift +++ b/MacTorn/MacTorn/Views/SettingsView.swift @@ -3,12 +3,20 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState @State private var inputKey: String = "" + @State private var showShortcutsEditor = false var body: some View { VStack(spacing: 16) { + // Header Image(systemName: "bolt.circle.fill") .font(.system(size: 48)) - .foregroundColor(.orange) + .foregroundStyle( + LinearGradient( + colors: [.orange, .yellow], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) Text("MacTorn") .font(.title2.bold()) @@ -17,6 +25,7 @@ struct SettingsView: View { .font(.caption) .foregroundColor(.secondary) + // API Key input SecureField("API Key", text: $inputKey) .textFieldStyle(.roundedBorder) .padding(.horizontal) @@ -31,6 +40,33 @@ struct SettingsView: View { Link("Get API Key from Torn", destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!) .font(.caption) + + Divider() + .padding(.vertical, 8) + + // Launch at Login + Toggle(isOn: Binding( + get: { appState.launchAtLogin.isEnabled }, + set: { _ in appState.launchAtLogin.toggle() } + )) { + Label("Launch at Login", systemImage: "power") + } + .toggleStyle(.switch) + .padding(.horizontal) + + // Shortcuts Editor + Button { + showShortcutsEditor.toggle() + } label: { + Label("Edit Shortcuts", systemImage: "keyboard") + } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + + if showShortcutsEditor { + ShortcutsEditorView() + .environmentObject(appState) + } } .padding() .onAppear { @@ -38,3 +74,106 @@ struct SettingsView: View { } } } + +// MARK: - Shortcuts Editor +struct ShortcutsEditorView: View { + @EnvironmentObject var appState: AppState + @State private var editingShortcut: KeyboardShortcut? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Quick Links") + .font(.caption.bold()) + + Spacer() + + Button("Reset") { + appState.shortcutsManager.resetToDefaults() + } + .font(.caption2) + .buttonStyle(.plain) + .foregroundColor(.red) + } + + ForEach(appState.shortcutsManager.shortcuts) { shortcut in + ShortcutRowView(shortcut: shortcut) { updated in + appState.shortcutsManager.updateShortcut(updated) + } + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + } +} + +struct ShortcutRowView: View { + let shortcut: KeyboardShortcut + let onUpdate: (KeyboardShortcut) -> Void + + @State private var isEditing = false + @State private var editedName: String = "" + @State private var editedURL: String = "" + @State private var editedKey: String = "" + + var body: some View { + VStack(spacing: 4) { + HStack { + if isEditing { + TextField("Name", text: $editedName) + .textFieldStyle(.roundedBorder) + .font(.caption) + .frame(width: 60) + + TextField("URL", text: $editedURL) + .textFieldStyle(.roundedBorder) + .font(.caption2) + + TextField("Key", text: $editedKey) + .textFieldStyle(.roundedBorder) + .font(.caption) + .frame(width: 30) + + Button("Save") { + var updated = shortcut + updated.name = editedName + updated.url = editedURL + updated.keyEquivalent = editedKey + onUpdate(updated) + isEditing = false + } + .font(.caption2) + .buttonStyle(.borderedProminent) + } else { + Text(shortcut.name) + .font(.caption) + .frame(width: 60, alignment: .leading) + + Text(shortcut.url) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Text("⌘⇧\(shortcut.keyEquivalent.uppercased())") + .font(.caption2.monospaced()) + .foregroundColor(.secondary) + + Button { + editedName = shortcut.name + editedURL = shortcut.url + editedKey = shortcut.keyEquivalent + isEditing = true + } label: { + Image(systemName: "pencil") + .font(.caption2) + } + .buttonStyle(.plain) + } + } + } + } +} diff --git a/MacTorn/MacTorn/Views/StatusView.swift b/MacTorn/MacTorn/Views/StatusView.swift index fea38d4..12a348f 100644 --- a/MacTorn/MacTorn/Views/StatusView.swift +++ b/MacTorn/MacTorn/Views/StatusView.swift @@ -4,101 +4,199 @@ struct StatusView: View { @EnvironmentObject var appState: AppState var body: some View { - VStack(alignment: .leading, spacing: 12) { - // Header + ScrollView { + VStack(alignment: .leading, spacing: 12) { + // Header + headerSection + + // Error state + if let error = appState.errorMsg { + errorSection(error) + } + + // Travel status + if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad { + travelSection(travel) + } + + // Bars + if let bars = appState.data?.bars { + barsSection(bars) + } + + // Cooldowns + if let cooldowns = appState.data?.cooldowns { + cooldownsSection(cooldowns) + } + + // Quick Links + quickLinksSection + } + .padding() + } + .frame(maxHeight: 400) + } + + // MARK: - Header + private var headerSection: some View { + HStack { + Text("Torn Status") + .font(.headline) + + Spacer() + + if appState.isLoading { + ProgressView() + .scaleEffect(0.6) + } else { + Button { + appState.refreshNow() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .foregroundColor(.secondary) + } + } + } + + // MARK: - Error + private func errorSection(_ error: String) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(error) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 4) + } + + // MARK: - Travel + private func travelSection(_ travel: Travel) -> some View { + VStack(alignment: .leading, spacing: 4) { HStack { - Text("Torn Status") - .font(.headline) - - Spacer() - - if appState.isLoading { - ProgressView() - .scaleEffect(0.6) - } else { - Button { - appState.refreshNow() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.plain) - .foregroundColor(.secondary) - } - } - - // Last updated - if let lastUpdated = appState.lastUpdated { - Text("Updated: \(lastUpdated, formatter: timeFormatter)") - .font(.caption2) - .foregroundColor(.secondary) - } - - // Error state - if let error = appState.errorMsg { - HStack { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(error) - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.vertical, 4) - } - - // Bars - if let bars = appState.data?.bars { - VStack(spacing: 8) { - ProgressBarView( - label: "Energy", - current: bars.energy.current, - maximum: bars.energy.maximum, - color: .green, - icon: "bolt.fill" - ) - - ProgressBarView( - label: "Nerve", - current: bars.nerve.current, - maximum: bars.nerve.maximum, - color: .red, - icon: "flame.fill" - ) - - ProgressBarView( - label: "Happy", - current: bars.happy.current, - maximum: bars.happy.maximum, - color: .yellow, - icon: "face.smiling.fill" - ) - - ProgressBarView( - label: "Life", - current: bars.life.current, - maximum: bars.life.maximum, - color: .pink, - icon: "heart.fill" - ) - } - } - - // Cooldowns - if let cooldowns = appState.data?.cooldowns { - Divider() - .padding(.vertical, 4) - - Text("Cooldowns") + Image(systemName: "airplane") + .foregroundColor(.blue) + Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)") .font(.caption.bold()) - .foregroundColor(.secondary) - - HStack(spacing: 16) { - CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill") - CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill") - CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill") + } + + if travel.isTraveling { + HStack { + Text("Arriving in:") + .font(.caption2) + .foregroundColor(.secondary) + Text(formatTime(travel.timeLeft)) + .font(.caption.monospacedDigit()) + .foregroundColor(.blue) } } } - .padding() + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } + + // MARK: - Bars + private func barsSection(_ bars: Bars) -> some View { + VStack(spacing: 10) { + ProgressBarView( + label: "Energy", + current: bars.energy.current, + maximum: bars.energy.maximum, + color: .green, + icon: "bolt.fill" + ) + + ProgressBarView( + label: "Nerve", + current: bars.nerve.current, + maximum: bars.nerve.maximum, + color: .red, + icon: "flame.fill" + ) + + ProgressBarView( + label: "Happy", + current: bars.happy.current, + maximum: bars.happy.maximum, + color: .yellow, + icon: "face.smiling.fill" + ) + + ProgressBarView( + label: "Life", + current: bars.life.current, + maximum: bars.life.maximum, + color: .pink, + icon: "heart.fill" + ) + } + } + + // MARK: - Cooldowns + private func cooldownsSection(_ cooldowns: Cooldowns) -> some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + + Text("Cooldowns") + .font(.caption.bold()) + .foregroundColor(.secondary) + + HStack(spacing: 16) { + CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill") + CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill") + CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill") + } + } + } + + // MARK: - Quick Links + private var quickLinksSection: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + + Text("Quick Links") + .font(.caption.bold()) + .foregroundColor(.secondary) + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 8) { + ForEach(appState.shortcutsManager.shortcuts) { shortcut in + Button { + appState.shortcutsManager.openURL(shortcut.url) + } label: { + Text(shortcut.name) + .font(.caption2) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .padding(.horizontal, 6) + .background(Color.accentColor.opacity(0.1)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } + } + } + } + + // MARK: - Helpers + private func formatTime(_ seconds: Int) -> String { + if seconds <= 0 { return "Ready" } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) + } + return String(format: "%d:%02d", minutes, secs) } private var timeFormatter: DateFormatter { @@ -117,11 +215,13 @@ struct CooldownItem: View { var body: some View { VStack(spacing: 2) { Image(systemName: icon) + .font(.caption) .foregroundColor(seconds > 0 ? .orange : .green) Text(formattedTime) .font(.caption2.monospacedDigit()) .foregroundColor(seconds > 0 ? .primary : .green) + .fontWeight(seconds <= 0 ? .bold : .regular) } .frame(maxWidth: .infinity) } @@ -130,12 +230,11 @@ struct CooldownItem: View { if seconds <= 0 { return "Ready" } - let minutes = seconds / 60 + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 let secs = seconds % 60 - if minutes >= 60 { - let hours = minutes / 60 - let mins = minutes % 60 - return String(format: "%d:%02d:%02d", hours, mins, secs) + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, secs) } return String(format: "%d:%02d", minutes, secs) } diff --git a/README.md b/README.md index df8add9..0098451 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,30 @@ # MacTorn -Torn notifier app for macOS +Menu bar companion for the Torn game on macOS. It polls the Torn API and surfaces your current bars, cooldowns, and travel status in a lightweight menu bar window. + +## Features +- Menu bar status window with energy, nerve, happy, and life bars. +- Cooldown timers for drug, medical, and booster. +- Travel status with remaining time and destination. +- Local notifications when bars fill, cooldowns end, or you land. +- Quick links grid for Torn pages with editable labels and URLs. +- Launch at login toggle. + +## Requirements +- macOS 13.0 or later. +- A Torn API key. + +## Setup +1. Open the app and paste your Torn API key. +2. Click "Save & Connect". +3. (Optional) Enable "Launch at Login" and edit Quick Links. + +Get an API key from `https://www.torn.com/preferences.php#tab=api`. + +## Build and Run +1. Open `MacTorn/MacTorn.xcodeproj` in Xcode. +2. Select the MacTorn scheme. +3. Run the app (it appears in the menu bar). + +## Notes +- The app polls the Torn API every 30 seconds. +- Your API key and Quick Links are stored locally in `UserDefaults`.