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 */; };
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 = "<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>"; };
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; };
/* 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 = "<group>";
@ -118,6 +129,7 @@
AAA10009 /* NotificationManager.swift */,
AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */,
AAA10016 /* SoundManager.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -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;
};

View file

@ -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)
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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