diff --git a/MacTorn-v1.0.zip b/MacTorn-v1.0.zip new file mode 100644 index 0000000..f9b3e38 Binary files /dev/null and b/MacTorn-v1.0.zip differ diff --git a/MacTorn/MacTorn.xcodeproj/project.pbxproj b/MacTorn/MacTorn.xcodeproj/project.pbxproj index 1ce9245..58d49c0 100644 --- a/MacTorn/MacTorn.xcodeproj/project.pbxproj +++ b/MacTorn/MacTorn.xcodeproj/project.pbxproj @@ -18,6 +18,10 @@ 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 */; }; + AAA00012 /* ChainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10013 /* ChainView.swift */; }; + AAA00013 /* StatusBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10014 /* StatusBadgesView.swift */; }; + AAA00014 /* EventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10015 /* EventsView.swift */; }; + AAA00015 /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10016 /* SoundManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -33,6 +37,10 @@ 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 = ""; }; + AAA10013 /* ChainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainView.swift; sourceTree = ""; }; + AAA10014 /* StatusBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgesView.swift; sourceTree = ""; }; + AAA10015 /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = ""; }; + AAA10016 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = ""; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -108,6 +116,9 @@ isa = PBXGroup; children = ( AAA10008 /* ProgressBarView.swift */, + AAA10013 /* ChainView.swift */, + AAA10014 /* StatusBadgesView.swift */, + AAA10015 /* EventsView.swift */, ); path = Components; sourceTree = ""; @@ -118,6 +129,7 @@ AAA10009 /* NotificationManager.swift */, AAA10011 /* LaunchAtLoginManager.swift */, AAA10012 /* ShortcutsManager.swift */, + AAA10016 /* SoundManager.swift */, ); path = Utilities; sourceTree = ""; @@ -201,6 +213,10 @@ AAA00009 /* NotificationManager.swift in Sources */, AAA00010 /* LaunchAtLoginManager.swift in Sources */, AAA00011 /* ShortcutsManager.swift in Sources */, + AAA00012 /* ChainView.swift in Sources */, + AAA00013 /* StatusBadgesView.swift in Sources */, + AAA00014 /* EventsView.swift in Sources */, + AAA00015 /* SoundManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/MacTorn/MacTorn/ViewModels/AppState.swift b/MacTorn/MacTorn/ViewModels/AppState.swift index 9e22282..6314fd7 100644 --- a/MacTorn/MacTorn/ViewModels/AppState.swift +++ b/MacTorn/MacTorn/ViewModels/AppState.swift @@ -6,12 +6,14 @@ import SwiftUI class AppState: ObservableObject { // MARK: - Persisted @AppStorage("apiKey") var apiKey: String = "" + @AppStorage("refreshInterval") var refreshInterval: Int = 30 // MARK: - Published State @Published var data: TornResponse? @Published var lastUpdated: Date? @Published var errorMsg: String? @Published var isLoading: Bool = false + @Published var notificationRules: [NotificationRule] = [] // MARK: - Managers let launchAtLogin = LaunchAtLoginManager() @@ -21,23 +23,52 @@ class AppState: ObservableObject { private var previousBars: Bars? private var previousCooldowns: Cooldowns? private var previousTravel: Travel? + private var previousChain: Chain? + private var previousStatus: Status? // MARK: - Timer private var timerCancellable: AnyCancellable? init() { + loadNotificationRules() startPolling() Task { await NotificationManager.shared.requestPermission() } } + func loadNotificationRules() { + if let data = UserDefaults.standard.data(forKey: "notificationRules"), + let rules = try? JSONDecoder().decode([NotificationRule].self, from: data) { + notificationRules = rules + } else { + notificationRules = NotificationRule.defaults + saveNotificationRules() + } + } + + func saveNotificationRules() { + if let data = try? JSONEncoder().encode(notificationRules) { + UserDefaults.standard.set(data, forKey: "notificationRules") + } + } + + func updateRule(_ rule: NotificationRule) { + if let index = notificationRules.firstIndex(where: { $0.id == rule.id }) { + notificationRules[index] = rule + saveNotificationRules() + } + } + func startPolling() { + // Stop existing timer + timerCancellable?.cancel() + // Initial fetch fetchData() - // Set up 30-second polling - timerCancellable = Timer.publish(every: 30, on: .main, in: .common) + // Set up polling with configurable interval + timerCancellable = Timer.publish(every: Double(refreshInterval), on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.fetchData() @@ -94,6 +125,8 @@ class AppState: ObservableObject { self.previousBars = decoded.bars self.previousCooldowns = decoded.cooldowns self.previousTravel = decoded.travel + self.previousChain = decoded.chain + self.previousStatus = decoded.status } case 403, 404: self.errorMsg = "Invalid API Key" @@ -110,57 +143,71 @@ class AppState: ObservableObject { } private func checkNotifications(newData: TornResponse) { - // Bar notifications + // Bar notifications with custom rules 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))" - ) - } + checkBarNotification(prevBar: prev.energy, currentBar: current.energy, barType: .energy) + checkBarNotification(prevBar: prev.nerve, currentBar: current.nerve, barType: .nerve) + checkBarNotification(prevBar: prev.happy, currentBar: current.happy, barType: .happy) + checkBarNotification(prevBar: prev.life, currentBar: current.life, barType: .life) } // Cooldown notifications if let prevCD = previousCooldowns, let currentCD = newData.cooldowns { if prevCD.drug > 0 && currentCD.drug == 0 { - NotificationManager.shared.send( - title: "Drug Ready! 💊", - body: "Drug cooldown has ended" - ) + NotificationManager.shared.send(title: "Drug Ready! 💊", body: "Drug cooldown has ended") } if prevCD.medical > 0 && currentCD.medical == 0 { - NotificationManager.shared.send( - title: "Medical Ready! 🏥", - body: "Medical cooldown has ended" - ) + NotificationManager.shared.send(title: "Medical Ready! 🏥", body: "Medical cooldown has ended") } if prevCD.booster > 0 && currentCD.booster == 0 { - NotificationManager.shared.send( - title: "Booster Ready! 🚀", - body: "Booster cooldown has ended" - ) + NotificationManager.shared.send(title: "Booster Ready! 🚀", body: "Booster cooldown has ended") } } // 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)" - ) + NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination)") + } + } + + // Chain timeout warning + if let chain = newData.chain, chain.isActive { + if chain.timeoutRemaining < 60 && chain.timeoutRemaining > 0 { + NotificationManager.shared.send(title: "Chain Expiring! ⚠️", body: "Chain timeout in \(chain.timeoutRemaining) seconds!") + } + } + + // Hospital/Jail release + if let prevStatus = previousStatus, let currentStatus = newData.status { + if !prevStatus.isOkay && currentStatus.isOkay { + NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free") + } + } + } + + private func checkBarNotification(prevBar: Bar, currentBar: Bar, barType: NotificationRule.BarType) { + let prevPct = prevBar.percentage + let currentPct = currentBar.percentage + + for rule in notificationRules where rule.enabled && rule.barType == barType { + let threshold = Double(rule.threshold) + + // Check if we crossed the threshold upwards + if prevPct < threshold && currentPct >= threshold { + let title: String + switch barType { + case .energy: title = "Energy \(rule.threshold)%! ⚡️" + case .nerve: title = "Nerve \(rule.threshold)%! 💪" + case .happy: title = "Happy \(rule.threshold)%! 😊" + case .life: title = "Life \(rule.threshold)%! ❤️" + } + NotificationManager.shared.send(title: title, body: "\(barType.rawValue) is now at \(currentBar.current)/\(currentBar.maximum)") + + // Play sound + if let sound = NotificationSound(rawValue: rule.soundName) { + SoundManager.shared.play(sound) + } } } } diff --git a/MacTorn/MacTorn/Views/SettingsView.swift b/MacTorn/MacTorn/Views/SettingsView.swift index 78ea6d2..886b251 100644 --- a/MacTorn/MacTorn/Views/SettingsView.swift +++ b/MacTorn/MacTorn/Views/SettingsView.swift @@ -3,74 +3,141 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState @State private var inputKey: String = "" + @State private var showNotificationSettings = false // Developer ID for tip feature (bombel) private let developerID = 2362436 var body: some View { - VStack(spacing: 16) { - // Header - Image(systemName: "bolt.circle.fill") - .font(.system(size: 48)) - .foregroundStyle( - LinearGradient( - colors: [.orange, .yellow], - startPoint: .topLeading, - endPoint: .bottomTrailing + ScrollView { + VStack(spacing: 16) { + // Header + Image(systemName: "bolt.circle.fill") + .font(.system(size: 48)) + .foregroundStyle( + LinearGradient( + colors: [.orange, .yellow], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) ) - ) - - Text("MacTorn") - .font(.title2.bold()) - - Text("Enter your Torn API Key") - .font(.caption) - .foregroundColor(.secondary) - - // API Key input - SecureField("API Key", text: $inputKey) - .textFieldStyle(.roundedBorder) + + Text("MacTorn") + .font(.title2.bold()) + + Text("Enter your Torn API Key") + .font(.caption) + .foregroundColor(.secondary) + + // API Key input + SecureField("API Key", text: $inputKey) + .textFieldStyle(.roundedBorder) + .padding(.horizontal) + + Button("Save & Connect") { + appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines) + appState.refreshNow() + } + .buttonStyle(.borderedProminent) + .disabled(inputKey.isEmpty) + + Link("Get API Key from Torn", + destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!) + .font(.caption) + + Divider() + .padding(.vertical, 8) + + // Refresh Interval + refreshIntervalSection + + // 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) - - Button("Save & Connect") { - appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines) - appState.refreshNow() + + // Notification Settings + Button { + showNotificationSettings.toggle() + } label: { + HStack { + Image(systemName: "bell.badge") + Text("Notification Rules") + Spacer() + Image(systemName: showNotificationSettings ? "chevron.up" : "chevron.down") + } + .font(.caption) + .padding(.horizontal) + } + .buttonStyle(.plain) + + if showNotificationSettings { + notificationRulesSection + } + + Divider() + .padding(.vertical, 4) + + // Tip Me section + tipMeSection + + // GitHub link + githubSection } - .buttonStyle(.borderedProminent) - .disabled(inputKey.isEmpty) - - 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) - - Divider() - .padding(.vertical, 4) - - // Tip Me section - tipMeSection - - // GitHub link - githubSection + .padding() } - .padding() + .fixedSize(horizontal: false, vertical: true) .onAppear { inputKey = appState.apiKey } } + // MARK: - Refresh Interval + private var refreshIntervalSection: some View { + HStack { + Label("Refresh Interval", systemImage: "clock") + .font(.caption) + + Spacer() + + Picker("", selection: Binding( + get: { appState.refreshInterval }, + set: { newValue in + appState.refreshInterval = newValue + appState.startPolling() + } + )) { + Text("15s").tag(15) + Text("30s").tag(30) + Text("60s").tag(60) + Text("120s").tag(120) + } + .pickerStyle(.segmented) + .frame(width: 180) + } + .padding(.horizontal) + } + + // MARK: - Notification Rules + private var notificationRulesSection: some View { + VStack(spacing: 8) { + ForEach(appState.notificationRules) { rule in + NotificationRuleRow(rule: rule) { updatedRule in + appState.updateRule(updatedRule) + } + } + } + .padding() + .background(Color.gray.opacity(0.1)) + .cornerRadius(8) + .padding(.horizontal) + } + // MARK: - Tip Me Section private var tipMeSection: some View { VStack(spacing: 8) { @@ -125,3 +192,46 @@ struct SettingsView: View { } } } + +// MARK: - Notification Rule Row +struct NotificationRuleRow: View { + let rule: NotificationRule + let onUpdate: (NotificationRule) -> Void + + var body: some View { + HStack { + Toggle(isOn: Binding( + get: { rule.enabled }, + set: { newValue in + var updated = rule + updated.enabled = newValue + onUpdate(updated) + } + )) { + HStack { + Text(rule.barType.rawValue) + .font(.caption) + Text("@ \(rule.threshold)%") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + .controlSize(.small) + + Picker("", selection: Binding( + get: { rule.soundName }, + set: { newValue in + var updated = rule + updated.soundName = newValue + onUpdate(updated) + } + )) { + ForEach(NotificationSound.allCases, id: \.rawValue) { sound in + Text(sound.displayName).tag(sound.rawValue) + } + } + .frame(width: 80) + } + } +} diff --git a/MacTorn/MacTorn/Views/StatusView.swift b/MacTorn/MacTorn/Views/StatusView.swift index d90a034..6159762 100644 --- a/MacTorn/MacTorn/Views/StatusView.swift +++ b/MacTorn/MacTorn/Views/StatusView.swift @@ -14,6 +14,16 @@ struct StatusView: View { errorSection(error) } + // Status badges (Hospital/Jail) + if let status = appState.data?.status { + StatusBadgesView(status: status) + } + + // Chain status + if let chain = appState.data?.chain { + ChainView(chain: chain) + } + // Travel status if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad { travelSection(travel) @@ -29,6 +39,16 @@ struct StatusView: View { cooldownsSection(cooldowns) } + // Messages badge + if appState.data?.unreadMessagesCount ?? 0 > 0 { + messagesBadge + } + + // Events + if let events = appState.data?.recentEvents, !events.isEmpty { + EventsView(events: events) + } + // Quick Links quickLinksSection } @@ -72,6 +92,27 @@ struct StatusView: View { } } + // MARK: - Messages Badge + private var messagesBadge: some View { + Button { + if let url = URL(string: "https://www.torn.com/messages.php") { + NSWorkspace.shared.open(url) + } + } label: { + HStack { + Image(systemName: "envelope.fill") + .foregroundColor(.blue) + Text("\(appState.data?.unreadMessagesCount ?? 0) unread messages") + .font(.caption) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.blue.opacity(0.1)) + .cornerRadius(6) + } + .buttonStyle(.plain) + } + // MARK: - Error private func errorSection(_ error: String) -> some View { HStack { @@ -210,12 +251,6 @@ struct StatusView: View { } return String(format: "%d:%02d", minutes, secs) } - - private var timeFormatter: DateFormatter { - let formatter = DateFormatter() - formatter.timeStyle = .short - return formatter - } } // MARK: - Cooldown Item @@ -239,9 +274,7 @@ struct CooldownItem: View { } private var formattedTime: String { - if seconds <= 0 { - return "Ready" - } + if seconds <= 0 { return "Ready" } let hours = seconds / 3600 let minutes = (seconds % 3600) / 60 let secs = seconds % 60 diff --git a/README.md b/README.md index 0098451..c632646 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,69 @@ # MacTorn -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. + +A native macOS menu bar app for monitoring your **Torn** game status. + +![macOS](https://img.shields.io/badge/macOS-13.0+-blue) +![Swift](https://img.shields.io/badge/Swift-5.0-orange) +![License](https://img.shields.io/badge/License-MIT-green) ## 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. + +- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress +- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns +- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown +- 🔗 **Chain Timer** - Active chain counter with timeout warning +- 🏥 **Hospital/Jail Status** - Countdown to release +- 📨 **Unread Messages** - Quick access to inbox +- 🔔 **Events Feed** - Recent activity at a glance +- 🔔 **Smart Notifications** - Custom threshold alerts with sound options +- ⚡ **Quick Links** - 8 configurable shortcuts to Torn pages +- 🚀 **Launch at Login** - Start automatically with macOS + +## Installation + +1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases) +2. Unzip and drag `MacTorn.app` to your Applications folder +3. Open MacTorn from Applications +4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api) + +> **Note**: On first launch, macOS may show a security warning. Right-click the app and select "Open" to bypass. ## 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. +- macOS 13.0 (Ventura) or later +- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages -Get an API key from `https://www.torn.com/preferences.php#tab=api`. +## Configuration -## 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). +### Refresh Interval +Choose polling frequency: 15s, 30s, 60s, or 120s -## Notes -- The app polls the Torn API every 30 seconds. -- Your API key and Quick Links are stored locally in `UserDefaults`. +### Notification Rules +Customize when to receive alerts: +- Energy/Nerve/Happy/Life at specific thresholds +- Sound selection per notification type + +### Quick Links +8 preset shortcuts to common Torn pages (fully editable) + +## Building from Source + +```bash +git clone https://github.com/pawelorzech/MacTorn.git +cd MacTorn/MacTorn +open MacTorn.xcodeproj +``` + +Press `Cmd + R` to build and run. + +## Support the Developer + +If you find MacTorn useful, send some Xanax or cash to **bombel** [[2362436](https://www.torn.com/profiles.php?XID=2362436)]! + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +Made with ⚡ for the Torn community