Add notification rules, status badges, and chain support

Introduces customizable notification rules with sound options and a UI for managing them in SettingsView. Adds support for displaying chain status, hospital/jail badges, unread messages, and recent events in StatusView. Makes the refresh interval configurable. Updates README with new features and installation instructions. Updates Xcode project to include new components and utilities.
This commit is contained in:
Paweł Orzech 2026-01-17 19:43:07 +00:00
parent 01b0b9a436
commit 53a234afcd
No known key found for this signature in database
6 changed files with 366 additions and 121 deletions

BIN
MacTorn-v1.0.zip Normal file

Binary file not shown.

View file

@ -18,6 +18,10 @@
AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; }; AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; };
AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; }; AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; };
AAA00011 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10012 /* ShortcutsManager.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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -33,6 +37,10 @@
AAA10010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; AAA10010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AAA10011 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = "<group>"; }; AAA10011 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = "<group>"; };
AAA10012 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = "<group>"; }; AAA10012 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = "<group>"; };
AAA10013 /* ChainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainView.swift; sourceTree = "<group>"; };
AAA10014 /* StatusBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgesView.swift; sourceTree = "<group>"; };
AAA10015 /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = "<group>"; };
AAA10016 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = "<group>"; };
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; }; AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -108,6 +116,9 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AAA10008 /* ProgressBarView.swift */, AAA10008 /* ProgressBarView.swift */,
AAA10013 /* ChainView.swift */,
AAA10014 /* StatusBadgesView.swift */,
AAA10015 /* EventsView.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@ -118,6 +129,7 @@
AAA10009 /* NotificationManager.swift */, AAA10009 /* NotificationManager.swift */,
AAA10011 /* LaunchAtLoginManager.swift */, AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */, AAA10012 /* ShortcutsManager.swift */,
AAA10016 /* SoundManager.swift */,
); );
path = Utilities; path = Utilities;
sourceTree = "<group>"; sourceTree = "<group>";
@ -201,6 +213,10 @@
AAA00009 /* NotificationManager.swift in Sources */, AAA00009 /* NotificationManager.swift in Sources */,
AAA00010 /* LaunchAtLoginManager.swift in Sources */, AAA00010 /* LaunchAtLoginManager.swift in Sources */,
AAA00011 /* ShortcutsManager.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; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -6,12 +6,14 @@ import SwiftUI
class AppState: ObservableObject { class AppState: ObservableObject {
// MARK: - Persisted // MARK: - Persisted
@AppStorage("apiKey") var apiKey: String = "" @AppStorage("apiKey") var apiKey: String = ""
@AppStorage("refreshInterval") var refreshInterval: Int = 30
// MARK: - Published State // MARK: - Published State
@Published var data: TornResponse? @Published var data: TornResponse?
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@Published var errorMsg: String? @Published var errorMsg: String?
@Published var isLoading: Bool = false @Published var isLoading: Bool = false
@Published var notificationRules: [NotificationRule] = []
// MARK: - Managers // MARK: - Managers
let launchAtLogin = LaunchAtLoginManager() let launchAtLogin = LaunchAtLoginManager()
@ -21,23 +23,52 @@ class AppState: ObservableObject {
private var previousBars: Bars? private var previousBars: Bars?
private var previousCooldowns: Cooldowns? private var previousCooldowns: Cooldowns?
private var previousTravel: Travel? private var previousTravel: Travel?
private var previousChain: Chain?
private var previousStatus: Status?
// MARK: - Timer // MARK: - Timer
private var timerCancellable: AnyCancellable? private var timerCancellable: AnyCancellable?
init() { init() {
loadNotificationRules()
startPolling() startPolling()
Task { Task {
await NotificationManager.shared.requestPermission() 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() { func startPolling() {
// Stop existing timer
timerCancellable?.cancel()
// Initial fetch // Initial fetch
fetchData() fetchData()
// Set up 30-second polling // Set up polling with configurable interval
timerCancellable = Timer.publish(every: 30, on: .main, in: .common) timerCancellable = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
.autoconnect() .autoconnect()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.fetchData() self?.fetchData()
@ -94,6 +125,8 @@ class AppState: ObservableObject {
self.previousBars = decoded.bars self.previousBars = decoded.bars
self.previousCooldowns = decoded.cooldowns self.previousCooldowns = decoded.cooldowns
self.previousTravel = decoded.travel self.previousTravel = decoded.travel
self.previousChain = decoded.chain
self.previousStatus = decoded.status
} }
case 403, 404: case 403, 404:
self.errorMsg = "Invalid API Key" self.errorMsg = "Invalid API Key"
@ -110,57 +143,71 @@ class AppState: ObservableObject {
} }
private func checkNotifications(newData: TornResponse) { private func checkNotifications(newData: TornResponse) {
// Bar notifications // Bar notifications with custom rules
if let prev = previousBars, let current = newData.bars { if let prev = previousBars, let current = newData.bars {
// Energy full notification checkBarNotification(prevBar: prev.energy, currentBar: current.energy, barType: .energy)
if prev.energy.current < prev.energy.maximum && checkBarNotification(prevBar: prev.nerve, currentBar: current.nerve, barType: .nerve)
current.energy.current >= current.energy.maximum { checkBarNotification(prevBar: prev.happy, currentBar: current.happy, barType: .happy)
NotificationManager.shared.send( checkBarNotification(prevBar: prev.life, currentBar: current.life, barType: .life)
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 // Cooldown notifications
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns { if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
if prevCD.drug > 0 && currentCD.drug == 0 { if prevCD.drug > 0 && currentCD.drug == 0 {
NotificationManager.shared.send( NotificationManager.shared.send(title: "Drug Ready! 💊", body: "Drug cooldown has ended")
title: "Drug Ready! 💊",
body: "Drug cooldown has ended"
)
} }
if prevCD.medical > 0 && currentCD.medical == 0 { if prevCD.medical > 0 && currentCD.medical == 0 {
NotificationManager.shared.send( NotificationManager.shared.send(title: "Medical Ready! 🏥", body: "Medical cooldown has ended")
title: "Medical Ready! 🏥",
body: "Medical cooldown has ended"
)
} }
if prevCD.booster > 0 && currentCD.booster == 0 { if prevCD.booster > 0 && currentCD.booster == 0 {
NotificationManager.shared.send( NotificationManager.shared.send(title: "Booster Ready! 🚀", body: "Booster cooldown has ended")
title: "Booster Ready! 🚀",
body: "Booster cooldown has ended"
)
} }
} }
// Travel notifications // Travel notifications
if let prevTravel = previousTravel, let currentTravel = newData.travel { if let prevTravel = previousTravel, let currentTravel = newData.travel {
// Landed notification
if prevTravel.isTraveling && !currentTravel.isTraveling { if prevTravel.isTraveling && !currentTravel.isTraveling {
NotificationManager.shared.send( NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination)")
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)
}
} }
} }
} }

View file

@ -3,11 +3,13 @@ import SwiftUI
struct SettingsView: View { struct SettingsView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var inputKey: String = "" @State private var inputKey: String = ""
@State private var showNotificationSettings = false
// Developer ID for tip feature (bombel) // Developer ID for tip feature (bombel)
private let developerID = 2362436 private let developerID = 2362436
var body: some View { var body: some View {
ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
// Header // Header
Image(systemName: "bolt.circle.fill") Image(systemName: "bolt.circle.fill")
@ -46,6 +48,9 @@ struct SettingsView: View {
Divider() Divider()
.padding(.vertical, 8) .padding(.vertical, 8)
// Refresh Interval
refreshIntervalSection
// Launch at Login // Launch at Login
Toggle(isOn: Binding( Toggle(isOn: Binding(
get: { appState.launchAtLogin.isEnabled }, get: { appState.launchAtLogin.isEnabled },
@ -56,6 +61,25 @@ struct SettingsView: View {
.toggleStyle(.switch) .toggleStyle(.switch)
.padding(.horizontal) .padding(.horizontal)
// 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() Divider()
.padding(.vertical, 4) .padding(.vertical, 4)
@ -66,11 +90,54 @@ struct SettingsView: View {
githubSection githubSection
} }
.padding() .padding()
}
.fixedSize(horizontal: false, vertical: true)
.onAppear { .onAppear {
inputKey = appState.apiKey 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 // MARK: - Tip Me Section
private var tipMeSection: some View { private var tipMeSection: some View {
VStack(spacing: 8) { 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)
}
}
}

View file

@ -14,6 +14,16 @@ struct StatusView: View {
errorSection(error) 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 // Travel status
if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad { if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad {
travelSection(travel) travelSection(travel)
@ -29,6 +39,16 @@ struct StatusView: View {
cooldownsSection(cooldowns) 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 // Quick Links
quickLinksSection 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 // MARK: - Error
private func errorSection(_ error: String) -> some View { private func errorSection(_ error: String) -> some View {
HStack { HStack {
@ -210,12 +251,6 @@ struct StatusView: View {
} }
return String(format: "%d:%02d", minutes, secs) return String(format: "%d:%02d", minutes, secs)
} }
private var timeFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter
}
} }
// MARK: - Cooldown Item // MARK: - Cooldown Item
@ -239,9 +274,7 @@ struct CooldownItem: View {
} }
private var formattedTime: String { private var formattedTime: String {
if seconds <= 0 { if seconds <= 0 { return "Ready" }
return "Ready"
}
let hours = seconds / 3600 let hours = seconds / 3600
let minutes = (seconds % 3600) / 60 let minutes = (seconds % 3600) / 60
let secs = seconds % 60 let secs = seconds % 60

View file

@ -1,30 +1,69 @@
# MacTorn # 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 ## Features
- Menu bar status window with energy, nerve, happy, and life bars.
- Cooldown timers for drug, medical, and booster. - 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
- Travel status with remaining time and destination. - ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns
- Local notifications when bars fill, cooldowns end, or you land. - ✈️ **Travel Monitoring** - Destination tracking with arrival countdown
- Quick links grid for Torn pages with editable labels and URLs. - 🔗 **Chain Timer** - Active chain counter with timeout warning
- Launch at login toggle. - 🏥 **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 ## Requirements
- macOS 13.0 or later.
- A Torn API key.
## Setup - macOS 13.0 (Ventura) or later
1. Open the app and paste your Torn API key. - Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages
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`. ## Configuration
## Build and Run ### Refresh Interval
1. Open `MacTorn/MacTorn.xcodeproj` in Xcode. Choose polling frequency: 15s, 30s, 60s, or 120s
2. Select the MacTorn scheme.
3. Run the app (it appears in the menu bar).
## Notes ### Notification Rules
- The app polls the Torn API every 30 seconds. Customize when to receive alerts:
- Your API key and Quick Links are stored locally in `UserDefaults`. - 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